diff --git a/apps/api/src/routes/content/post.test.ts b/apps/api/src/routes/content/post.test.ts index 1b0ad95a..9a6cfb59 100644 --- a/apps/api/src/routes/content/post.test.ts +++ b/apps/api/src/routes/content/post.test.ts @@ -17,16 +17,12 @@ describe("Post Routes Tests", () => { test("returns a post with its authors and a chapter list sorted by collectionOrder", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "chapter-two", - data: [ - { - title: "Chapter Two", - description: "The second chapter", - bannerImage: "content/banner.png", - socialImage: "content/social.png", - wordCount: 500, - publishedAt: new Date("2024-01-15T00:00:00Z"), - }, - ], + title: "Chapter Two", + description: "The second chapter", + bannerImage: "content/banner.png", + socialImage: "content/social.png", + wordCount: 500, + publishedAt: new Date("2024-01-15T00:00:00Z"), authors: [ { slug: "crutchcorn", @@ -41,12 +37,12 @@ describe("Post Routes Tests", () => { { slug: "chapter-two", collectionOrder: 1, - data: [{ title: "Chapter Two" }], + title: "Chapter Two", }, { slug: "chapter-one", collectionOrder: 0, - data: [{ title: "Chapter One" }], + title: "Chapter One", }, ], }, @@ -96,16 +92,12 @@ describe("Post Routes Tests", () => { test("omits collection when the post is not part of a collection", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "standalone-post", - data: [ - { - title: "Standalone Post", - description: "A post with no collection", - bannerImage: null, - socialImage: null, - wordCount: 200, - publishedAt: new Date("2024-01-15T00:00:00Z"), - }, - ], + title: "Standalone Post", + description: "A post with no collection", + bannerImage: null, + socialImage: null, + wordCount: 200, + publishedAt: new Date("2024-01-15T00:00:00Z"), authors: [ { slug: "crutchcorn", name: "Corbin Crutchley", profileImage: null }, ], @@ -139,16 +131,12 @@ describe("Post Routes Tests", () => { test("returns 404 when the requested post is unpublished for the locale", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "draft-post", - data: [ - { - title: "Draft Post", - description: "Not yet published", - bannerImage: null, - socialImage: null, - wordCount: 100, - publishedAt: null, - }, - ], + title: "Draft Post", + description: "Not yet published", + bannerImage: null, + socialImage: null, + wordCount: 100, + publishedAt: null, authors: [], collection: null, } as never); @@ -170,16 +158,12 @@ describe("Post Routes Tests", () => { test("excludes unpublished sibling chapters from the chapter list", async () => { vi.mocked(db.query.posts.findFirst).mockResolvedValue({ slug: "chapter-one", - data: [ - { - title: "Chapter One", - description: "The first chapter", - bannerImage: null, - socialImage: null, - wordCount: 300, - publishedAt: new Date("2024-01-15T00:00:00Z"), - }, - ], + 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", @@ -188,27 +172,20 @@ describe("Post Routes Tests", () => { { slug: "chapter-one", collectionOrder: 0, - data: [ - { - title: "Chapter One", - publishedAt: new Date("2024-01-15T00:00:00Z"), - }, - ], + title: "Chapter One", + publishedAt: new Date("2024-01-15T00:00:00Z"), }, { slug: "chapter-two-draft", collectionOrder: 1, - data: [{ title: "Chapter Two (Draft)", publishedAt: null }], + title: "Chapter Two (Draft)", + publishedAt: null, }, { slug: "chapter-three", collectionOrder: 2, - data: [ - { - title: "Chapter Three", - publishedAt: new Date("2024-01-20T00:00:00Z"), - }, - ], + title: "Chapter Three", + publishedAt: new Date("2024-01-20T00:00:00Z"), }, ], }, @@ -263,27 +240,5 @@ describe("Post Routes Tests", () => { } `); }); - - test("returns 404 when the post has no post_data row for the requested locale", async () => { - vi.mocked(db.query.posts.findFirst).mockResolvedValue({ - slug: "spanish-only-post", - data: [], - authors: [], - collection: null, - } as never); - - const response = await app.inject({ - method: "GET", - url: "/content/post/spanish-only-post", - query: { locale: "en" }, - }); - - expect(response.statusCode).toBe(404); - expect(response.json()).toMatchInlineSnapshot(` - { - "error": "Post not found", - } - `); - }); }); }); diff --git a/apps/api/src/routes/content/post.ts b/apps/api/src/routes/content/post.ts index 01629a65..f200cf6f 100644 --- a/apps/api/src/routes/content/post.ts +++ b/apps/api/src/routes/content/post.ts @@ -10,6 +10,7 @@ const PostParamsSchema = Type.Object({ const PostQueryParamsSchema = Type.Object({ locale: Type.String({ default: "en" }), + branch: Type.String({ default: "main" }), }); const PostResponseSchema = Type.Intersect( @@ -99,22 +100,20 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { }, async (request, reply) => { const { slug } = request.params; - const { locale } = request.query; + const { locale, branch } = request.query; const post = await db.query.posts.findFirst({ - where: { slug }, + where: { slug, locale, branch }, + columns: { + slug: true, + title: true, + description: true, + bannerImage: true, + socialImage: true, + wordCount: true, + publishedAt: true, + }, with: { - data: { - columns: { - title: true, - description: true, - bannerImage: true, - socialImage: true, - wordCount: true, - publishedAt: true, - }, - where: { locale }, - }, authors: { columns: { slug: true, name: true, profileImage: true } }, collection: { with: { @@ -123,22 +122,20 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { where: { locale }, }, posts: { - columns: { slug: true, collectionOrder: true }, - with: { - data: { - columns: { title: true, publishedAt: true }, - where: { locale }, - }, + columns: { + slug: true, + collectionOrder: true, + title: true, + publishedAt: true, }, + where: { locale, branch }, }, }, }, }, }); - const postData = post?.data[0]; - - if (!post || !postData || postData.publishedAt === null) { + if (!post || post.publishedAt === null) { reply.code(404); reply.send({ error: "Post not found" }); return; @@ -152,31 +149,28 @@ const postRoutes: FastifyPluginAsync = async (fastify) => { slug: post.collection.slug, title: collectionData.title, chapters: post.collection.posts - .filter( - (chapter) => - chapter.data[0] && chapter.data[0].publishedAt !== null, - ) + .filter((chapter) => chapter.publishedAt !== null) .sort((a, b) => a.collectionOrder - b.collectionOrder) .map((chapter) => ({ slug: chapter.slug, - title: chapter.data[0].title, + title: chapter.title, })), } : undefined; const response: PostResponse = { slug: post.slug, - title: postData.title, - description: postData.description, - bannerUrl: postData.bannerImage - ? createImageUrl(postData.bannerImage) + title: post.title, + description: post.description, + bannerUrl: post.bannerImage + ? createImageUrl(post.bannerImage) : undefined, - socialImageUrl: postData.socialImage - ? createImageUrl(postData.socialImage) + socialImageUrl: post.socialImage + ? createImageUrl(post.socialImage) : undefined, - wordCount: postData.wordCount, - publishedAt: postData.publishedAt - ? postData.publishedAt.toISOString() + wordCount: post.wordCount, + publishedAt: post.publishedAt + ? post.publishedAt.toISOString() : undefined, authors: post.authors.map((author) => ({ id: author.slug, diff --git a/apps/api/src/routes/content/posts.test.ts b/apps/api/src/routes/content/posts.test.ts index cbe2150e..04eaab32 100644 --- a/apps/api/src/routes/content/posts.test.ts +++ b/apps/api/src/routes/content/posts.test.ts @@ -1,52 +1,6 @@ import fastify, { type FastifyInstance } from "fastify"; import postsRoutes from "./posts.ts"; -import { db, postAuthors, postData, posts } from "@playfulprogramming/db"; -import { and, asc, desc, eq, isNotNull, sql } from "drizzle-orm"; - -function mockPostsSelectChain(rows: unknown[]) { - const offset = vi.fn().mockResolvedValue(rows); - const limit = vi.fn().mockReturnValue({ offset }); - const orderBy = vi.fn().mockReturnValue({ limit }); - const groupBy = vi.fn().mockReturnValue({ orderBy }); - const where = vi.fn().mockReturnValue({ groupBy }); - const leftJoinPostTags = vi.fn().mockReturnValue({ where }); - const innerJoinPostData = vi - .fn() - .mockReturnValue({ leftJoin: leftJoinPostTags }); - const from = vi.fn().mockReturnValue({ innerJoin: innerJoinPostData }); - - return { - from, - innerJoinPostData, - leftJoinPostTags, - where, - groupBy, - orderBy, - limit, - offset, - }; -} - -function mockAuthorsSelectChain(rows: unknown[]) { - const where = vi.fn().mockResolvedValue(rows); - const innerJoin = vi.fn().mockReturnValue({ where }); - const from = vi.fn().mockReturnValue({ innerJoin }); - - return { from, innerJoin, where }; -} - -function mockDbSelect(postsRows: unknown[], authorRows: unknown[] = []) { - const postsChain = mockPostsSelectChain(postsRows); - const authorsChain = mockAuthorsSelectChain(authorRows); - - const selectMock = vi.mocked(db.select); - selectMock.mockReturnValueOnce({ from: postsChain.from } as never); - if (postsRows.length > 0) { - selectMock.mockReturnValueOnce({ from: authorsChain.from } as never); - } - - return { postsChain, authorsChain }; -} +import { db } from "@playfulprogramming/db"; describe("Posts Routes Tests", () => { let app: FastifyInstance; @@ -61,26 +15,23 @@ describe("Posts Routes Tests", () => { describe("/content/posts", () => { test("returns posts with their authors and tags", async () => { - mockDbSelect( - [ - { - slug: "example-post", - title: "Example Post", - bannerImage: "content/banner.png", - wordCount: 1200, - publishedAt: new Date("2024-01-15T00:00:00Z"), - tags: ["react", "javascript"], - }, - ], - [ - { - postSlug: "example-post", - id: "crutchcorn", - name: "Corbin Crutchley", - profileImage: "content/profile.png", - }, - ], - ); + vi.mocked(db.query.posts.findMany).mockResolvedValue([ + { + slug: "example-post", + title: "Example Post", + bannerImage: "content/banner.png", + wordCount: 1200, + publishedAt: new Date("2024-01-15T00:00:00Z"), + authors: [ + { + slug: "crutchcorn", + name: "Corbin Crutchley", + profileImage: "content/profile.png", + }, + ], + tags: [{ tag: "react" }, { tag: "javascript" }], + }, + ] as never); const response = await app.inject({ method: "GET", @@ -114,16 +65,17 @@ describe("Posts Routes Tests", () => { }); test("does not include a description or excerpt field", async () => { - mockDbSelect([ + vi.mocked(db.query.posts.findMany).mockResolvedValue([ { slug: "example-post", title: "Example Post", bannerImage: null, wordCount: 300, publishedAt: new Date("2024-01-15T00:00:00Z"), + authors: [], tags: [], }, - ]); + ] as never); const response = await app.inject({ method: "GET", @@ -138,32 +90,28 @@ describe("Posts Routes Tests", () => { }); test("groups multiple co-authors under the same post", async () => { - mockDbSelect( - [ - { - slug: "example-post", - title: "Example Post", - bannerImage: null, - wordCount: 300, - publishedAt: new Date("2024-01-15T00:00:00Z"), - tags: [], - }, - ], - [ - { - postSlug: "example-post", - id: "crutchcorn", - name: "Corbin Crutchley", - profileImage: null, - }, - { - postSlug: "example-post", - id: "fennifith", - name: "James Fenn", - profileImage: null, - }, - ], - ); + vi.mocked(db.query.posts.findMany).mockResolvedValue([ + { + slug: "example-post", + title: "Example Post", + bannerImage: null, + wordCount: 300, + publishedAt: new Date("2024-01-15T00:00:00Z"), + authors: [ + { + slug: "crutchcorn", + name: "Corbin Crutchley", + profileImage: null, + }, + { + slug: "fennifith", + name: "James Fenn", + profileImage: null, + }, + ], + tags: [], + }, + ] as never); const response = await app.inject({ method: "GET", @@ -178,7 +126,7 @@ describe("Posts Routes Tests", () => { }); test("returns an empty list when there are no posts", async () => { - mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -191,7 +139,7 @@ describe("Posts Routes Tests", () => { }); test("paginates using page and limit query params", async () => { - const { postsChain } = mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -200,12 +148,16 @@ describe("Posts Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(postsChain.limit).toBeCalledWith(10); - expect(postsChain.offset).toBeCalledWith(20); + expect(db.query.posts.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 10, + offset: 20, + }), + ); }); test("defaults to sort=newest, ordering by publishedAt descending", async () => { - const { postsChain } = mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -214,11 +166,15 @@ describe("Posts Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(postsChain.orderBy).toBeCalledWith(desc(postData.publishedAt)); + expect(db.query.posts.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { publishedAt: "desc" }, + }), + ); }); test("sort=oldest orders by publishedAt ascending", async () => { - const { postsChain } = mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -227,11 +183,15 @@ describe("Posts Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(postsChain.orderBy).toBeCalledWith(asc(postData.publishedAt)); + expect(db.query.posts.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { publishedAt: "asc" }, + }), + ); }); test("only includes posts with a publishedAt date and noindex false", async () => { - const { postsChain } = mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -240,13 +200,18 @@ describe("Posts Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(postsChain.where).toBeCalledWith( - and(isNotNull(postData.publishedAt), eq(postData.noindex, false)), + expect(db.query.posts.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + publishedAt: { isNotNull: true }, + noindex: false, + }), + }), ); }); test("author filter adds an EXISTS clause matching co-authors, not just a primary author", async () => { - const { postsChain } = mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", @@ -255,17 +220,17 @@ describe("Posts Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(postsChain.where).toBeCalledWith( - and( - isNotNull(postData.publishedAt), - eq(postData.noindex, false), - sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${postAuthors.authorSlug} = ${"crutchcorn"})`, - ), + expect(db.query.posts.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + RAW: expect.any(Function), + }), + }), ); }); test("returns an empty list for an author with no posts", async () => { - mockDbSelect([]); + vi.mocked(db.query.posts.findMany).mockResolvedValue([]); const response = await app.inject({ method: "GET", diff --git a/apps/api/src/routes/content/posts.ts b/apps/api/src/routes/content/posts.ts index a6c5cb25..06e1bd17 100644 --- a/apps/api/src/routes/content/posts.ts +++ b/apps/api/src/routes/content/posts.ts @@ -1,13 +1,6 @@ import type { FastifyPluginAsync } from "fastify"; -import { - db, - posts, - postData, - postTags, - postAuthors, - profiles, -} from "@playfulprogramming/db"; -import { and, asc, desc, eq, inArray, isNotNull, sql } from "drizzle-orm"; +import { db, postAuthors } from "@playfulprogramming/db"; +import { sql } from "drizzle-orm"; import { Type, type Static } from "typebox"; import { createImageUrl } from "../../utils.ts"; import { PostBaseSchema } from "./postBaseSchema.ts"; @@ -49,8 +42,6 @@ const PostsResponseSchema = Type.Array( type PostsResponse = Static; -type PostAuthorEntry = PostsResponse[number]["authors"][number]; - const postsRoutes: FastifyPluginAsync = async (fastify) => { fastify.get<{ Querystring: Static; @@ -74,84 +65,46 @@ const postsRoutes: FastifyPluginAsync = async (fastify) => { async (request, reply) => { const { locale, page, limit, sort, author } = request.query; - const conditions = [ - isNotNull(postData.publishedAt), - eq(postData.noindex, false), - ]; - if (author) { - conditions.push( - sql`exists (select 1 from ${postAuthors} where ${postAuthors.postSlug} = ${posts.slug} and ${postAuthors.authorSlug} = ${author})`, - ); - } - - const rows = await db - .select({ - slug: posts.slug, - title: postData.title, - bannerImage: postData.bannerImage, - wordCount: postData.wordCount, - publishedAt: postData.publishedAt, - tags: sql< - string[] - >`coalesce(array_agg(distinct ${postTags.tag}) filter (where ${postTags.tag} is not null), '{}')`, - }) - .from(posts) - .innerJoin( - postData, - and( - eq(postData.slug, posts.slug), - eq(postData.locale, locale), - // No documented "current version" convention exists anywhere else in - // the query layer (post.ts doesn't filter by version either); this - // pins to the schema's default empty-string version to avoid - // returning duplicate rows per slug/locale until one is established. - eq(postData.version, ""), - ), - ) - .leftJoin(postTags, eq(postTags.postSlug, posts.slug)) - .where(and(...conditions)) - .groupBy( - posts.slug, - postData.title, - postData.bannerImage, - postData.wordCount, - postData.publishedAt, - ) - .orderBy( - sort === "oldest" - ? asc(postData.publishedAt) - : desc(postData.publishedAt), - ) - .limit(limit) - .offset(page * limit); - - const slugs = rows.map((row) => row.slug); - - const authorRows = slugs.length - ? await db - .select({ - postSlug: postAuthors.postSlug, - id: profiles.slug, - name: profiles.name, - profileImage: profiles.profileImage, - }) - .from(postAuthors) - .innerJoin(profiles, eq(profiles.slug, postAuthors.authorSlug)) - .where(inArray(postAuthors.postSlug, slugs)) - : []; - - const authorsByPostSlug = new Map(); - for (const authorRow of authorRows) { - const entries = authorsByPostSlug.get(authorRow.postSlug) ?? []; - entries.push({ - id: authorRow.id, - name: authorRow.name, - profileImageUrl: authorRow.profileImage - ? createImageUrl(authorRow.profileImage) + const rows = await db.query.posts.findMany({ + columns: { + slug: true, + title: true, + bannerImage: true, + wordCount: true, + publishedAt: true, + }, + where: { + locale, + branch: "main", + publishedAt: { + isNotNull: true, + }, + noindex: false, + RAW: author + ? (posts) => + sql`exists (select 1 from ${postAuthors} where ${postAuthors.postId} = ${posts.id} and ${postAuthors.authorSlug} = ${author})` : undefined, - }); - authorsByPostSlug.set(authorRow.postSlug, entries); - } + }, + with: { + authors: { + columns: { + slug: true, + name: true, + profileImage: true, + }, + }, + tags: { + columns: { + tag: true, + }, + }, + }, + orderBy: { + publishedAt: sort === "oldest" ? "asc" : "desc", + }, + limit: limit, + offset: page * limit, + }); const postsResponse: PostsResponse = rows.map((row) => ({ slug: row.slug, @@ -163,8 +116,14 @@ const postsRoutes: FastifyPluginAsync = async (fastify) => { publishedAt: row.publishedAt ? row.publishedAt.toISOString() : undefined, - authors: authorsByPostSlug.get(row.slug) ?? [], - tags: row.tags, + authors: row.authors.map((authorRow) => ({ + id: authorRow.slug, + name: authorRow.name, + profileImageUrl: authorRow.profileImage + ? createImageUrl(authorRow.profileImage) + : undefined, + })), + tags: row.tags.map(({ tag }) => tag), })); reply.code(200); diff --git a/apps/api/src/routes/content/profiles.test.ts b/apps/api/src/routes/content/profiles.test.ts index d438aaf4..5ce89258 100644 --- a/apps/api/src/routes/content/profiles.test.ts +++ b/apps/api/src/routes/content/profiles.test.ts @@ -1,6 +1,6 @@ import fastify, { type FastifyInstance } from "fastify"; import profilesRoutes from "./profiles.ts"; -import { db, profiles, postAuthors, postData } from "@playfulprogramming/db"; +import { db, profiles, postAuthors, posts } from "@playfulprogramming/db"; import { and, asc, countDistinct, desc, eq, isNotNull } from "drizzle-orm"; function mockSelectChain(rows: unknown[]) { @@ -8,10 +8,10 @@ function mockSelectChain(rows: unknown[]) { const limit = vi.fn().mockReturnValue({ offset }); const orderBy = vi.fn().mockReturnValue({ limit }); const groupBy = vi.fn().mockReturnValue({ orderBy }); - const leftJoinPostData = vi.fn().mockReturnValue({ groupBy }); + const leftJoinPosts = vi.fn().mockReturnValue({ groupBy }); const leftJoinPostAuthors = vi .fn() - .mockReturnValue({ leftJoin: leftJoinPostData }); + .mockReturnValue({ leftJoin: leftJoinPosts }); const from = vi.fn().mockReturnValue({ leftJoin: leftJoinPostAuthors }); vi.mocked(db.select).mockReturnValue({ from } as never); @@ -19,7 +19,7 @@ function mockSelectChain(rows: unknown[]) { return { from, leftJoinPostAuthors, - leftJoinPostData, + leftJoinPosts, groupBy, orderBy, limit, @@ -106,8 +106,8 @@ describe("Profiles Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(chain.limit).toBeCalledWith(10); - expect(chain.offset).toBeCalledWith(20); + expect(chain.limit).toHaveBeenCalledWith(10); + expect(chain.offset).toHaveBeenCalledWith(20); }); test("defaults to sortBy=id, ordering by profile slug ascending", async () => { @@ -120,7 +120,7 @@ describe("Profiles Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(chain.orderBy).toBeCalledWith(asc(profiles.slug)); + expect(chain.orderBy).toHaveBeenCalledWith(asc(profiles.slug)); }); test("sortBy=posts orders authors by descending post count", async () => { @@ -148,8 +148,8 @@ describe("Profiles Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(chain.orderBy).toBeCalledWith( - desc(countDistinct(postData.slug)), + expect(chain.orderBy).toHaveBeenCalledWith( + desc(countDistinct(posts.slug)), asc(profiles.slug), ); expect( @@ -167,7 +167,7 @@ describe("Profiles Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(chain.leftJoinPostAuthors).toBeCalledWith( + expect(chain.leftJoinPostAuthors).toHaveBeenCalledWith( postAuthors, eq(postAuthors.authorSlug, profiles.slug), ); @@ -183,12 +183,12 @@ describe("Profiles Routes Tests", () => { }); expect(response.statusCode).toBe(200); - expect(chain.leftJoinPostData).toBeCalledWith( - postData, + expect(chain.leftJoinPosts).toHaveBeenCalledWith( + posts, and( - eq(postData.slug, postAuthors.postSlug), - isNotNull(postData.publishedAt), - eq(postData.noindex, false), + eq(posts.id, postAuthors.postId), + isNotNull(posts.publishedAt), + eq(posts.noindex, false), ), ); }); diff --git a/apps/api/src/routes/content/profiles.ts b/apps/api/src/routes/content/profiles.ts index 0dbbcb2d..e245995b 100644 --- a/apps/api/src/routes/content/profiles.ts +++ b/apps/api/src/routes/content/profiles.ts @@ -1,5 +1,5 @@ import type { FastifyPluginAsync } from "fastify"; -import { db, profiles, postAuthors, postData } from "@playfulprogramming/db"; +import { db, profiles, postAuthors, posts } from "@playfulprogramming/db"; import { and, asc, countDistinct, desc, eq, isNotNull } from "drizzle-orm"; import { Type, type Static } from "typebox"; import { createImageUrl } from "../../utils.ts"; @@ -76,16 +76,16 @@ const profilesRoutes: FastifyPluginAsync = async (fastify) => { name: profiles.name, description: profiles.description, profileImage: profiles.profileImage, - postsCount: countDistinct(postData.slug), + postsCount: countDistinct(posts.slug), }) .from(profiles) .leftJoin(postAuthors, eq(postAuthors.authorSlug, profiles.slug)) .leftJoin( - postData, + posts, and( - eq(postData.slug, postAuthors.postSlug), - isNotNull(postData.publishedAt), - eq(postData.noindex, false), + eq(posts.id, postAuthors.postId), + isNotNull(posts.publishedAt), + eq(posts.noindex, false), ), ) .groupBy( @@ -96,7 +96,7 @@ const profilesRoutes: FastifyPluginAsync = async (fastify) => { ) .orderBy( ...(sortBy === "posts" - ? [desc(countDistinct(postData.slug)), asc(profiles.slug)] + ? [desc(countDistinct(posts.slug)), asc(profiles.slug)] : [asc(profiles.slug)]), ) .limit(queryParams.limit) diff --git a/apps/api/test-utils/setup.ts b/apps/api/test-utils/setup.ts index aac55151..767257f8 100644 --- a/apps/api/test-utils/setup.ts +++ b/apps/api/test-utils/setup.ts @@ -22,12 +22,11 @@ vi.mock("@playfulprogramming/redis", () => { vi.mock("@playfulprogramming/db", () => { return { posts: { - slug: {}, - collectionSlug: {}, - }, - postData: { + id: {}, slug: {}, locale: {}, + branch: {}, + collectionSlug: {}, title: {}, bannerImage: {}, wordCount: {}, @@ -61,6 +60,7 @@ vi.mock("@playfulprogramming/db", () => { }, posts: { findFirst: vi.fn(), + findMany: vi.fn(), }, profiles: { findMany: vi.fn(), diff --git a/apps/worker/src/tasks/grant-author-achievements/processor.ts b/apps/worker/src/tasks/grant-author-achievements/processor.ts index 1669615a..d91cfaae 100644 --- a/apps/worker/src/tasks/grant-author-achievements/processor.ts +++ b/apps/worker/src/tasks/grant-author-achievements/processor.ts @@ -3,13 +3,13 @@ import { db, profileAchievements, postAuthors, - postData, collectionAuthors, collectionData, + posts, } from "@playfulprogramming/db"; import * as github from "@playfulprogramming/github-api"; import { createProcessor } from "../../createProcessor.ts"; -import { and, eq, inArray, max, count, ne, isNotNull } from "drizzle-orm"; +import { and, eq, inArray, count, ne, isNotNull } from "drizzle-orm"; import { ACHIEVEMENT_RULES, ALL_POSSIBLE_AUTO_IDS } from "./achievement-ids.ts"; export default createProcessor(Tasks.GRANT_AUTHOR_ACHIEVEMENTS, async (job) => { @@ -39,21 +39,24 @@ export default createProcessor(Tasks.GRANT_AUTHOR_ACHIEVEMENTS, async (job) => { // (slug, locale, version). const wordCountRows = await db .select({ - postSlug: postAuthors.postSlug, - wordCount: max(postData.wordCount), + postId: posts.id, + wordCount: posts.wordCount, }) - .from(postAuthors) + .from(posts) .innerJoin( - postData, - and(eq(postData.slug, postAuthors.postSlug), eq(postData.locale, "en")), - ) - .where( + postAuthors, and( + eq(postAuthors.postId, posts.id), eq(postAuthors.authorSlug, profileSlug), - isNotNull(postData.publishedAt), ), ) - .groupBy(postAuthors.postSlug); + .where( + and( + eq(posts.locale, "en"), + eq(posts.branch, "main"), + isNotNull(posts.publishedAt), + ), + ); const postCount = wordCountRows.length; const maxPostWordCount = wordCountRows.reduce( @@ -69,13 +72,13 @@ export default createProcessor(Tasks.GRANT_AUTHOR_ACHIEVEMENTS, async (job) => { // We already have the post slugs, so just look for rows with a different authorSlug. let hasCoAuthoredPost = false; if (wordCountRows.length > 0) { - const postSlugs = wordCountRows.map((r) => r.postSlug); + const postIds = wordCountRows.map((r) => r.postId); const coAuthorRows = await db .select({ value: count() }) .from(postAuthors) .where( and( - inArray(postAuthors.postSlug, postSlugs), + inArray(postAuthors.postId, postIds), ne(postAuthors.authorSlug, profileSlug), ), ); diff --git a/apps/worker/src/tasks/sync-post/processor.test.ts b/apps/worker/src/tasks/sync-post/processor.test.ts index 43f9ae22..a85a3687 100644 --- a/apps/worker/src/tasks/sync-post/processor.test.ts +++ b/apps/worker/src/tasks/sync-post/processor.test.ts @@ -3,56 +3,39 @@ import { createJob, type TaskInputs, Tasks } from "@playfulprogramming/bullmq"; import type { Job } from "bullmq"; import { posts, - postData, postAuthors, postTags, postAttachments, db, + attachments, } from "@playfulprogramming/db"; import { s3 } from "@playfulprogramming/s3"; import * as github from "@playfulprogramming/github-api"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Readable } from "node:stream"; const ONE_PIXEL_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR42mMAAQAABQABoIJXOQAAAABJRU5ErkJggg=="; -test("Syncs a standalone post successfully", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostTagsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postTags) { - return { values: insertPostTagsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); +const selectExistingAttachments = db + .select(expect.anything()) + .from(attachments) + .innerJoin(expect.anything(), expect.anything()) + .innerJoin(expect.anything(), expect.anything()).where; +const selectPreviousAuthors = db + .select(expect.anything()) + .from(posts) + .innerJoin(postAuthors, expect.anything()).where; +const insertPostReturning = db + .insert(posts) + .values(expect.anything()).returning; - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); +test("Syncs a standalone post successfully", async () => { + const postId = ":test-post-uuid:"; - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); // Mock GitHub: return folder listing with index.md vi.mocked(github.getContents).mockImplementation(((params: { @@ -107,7 +90,7 @@ This is the post content. } as unknown as Job); // Assert: Markdown was uploaded to S3 - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/example-post/en/content.md", undefined, @@ -116,11 +99,13 @@ This is the post content. ); // Assert: Post metadata was saved to database - expect(insertPostDataValues).toBeCalledWith({ + expect(db.insert(posts).values).toHaveBeenCalledWith({ slug: "example-post", locale: "en", + branch: "main", + collectionOrder: undefined, title: "Example Post", - version: "", + versionName: "", description: "A test post", wordCount: 10, socialImage: null, @@ -134,55 +119,31 @@ This is the post content. }, }); - // Assert: Old authors deleted, new author inserted - expect(deleteWhere).toBeCalledWith(eq(postAuthors.postSlug, "example-post")); - expect(insertPostAuthorsValues).toBeCalledWith([ + expect(db.insert(postAuthors).values).toHaveBeenCalledWith([ { - postSlug: "example-post", + postId, authorSlug: "example-author", }, ]); - // Assert: Old tags deleted, new tags inserted - expect(deleteWhere).toBeCalledWith(eq(postTags.postSlug, "example-post")); - expect(insertPostTagsValues).toBeCalledWith([ + expect(db.insert(postTags).values).toHaveBeenCalledWith([ { - postSlug: "example-post", + postId, tag: "javascript", }, { - postSlug: "example-post", + postId, tag: "tutorial", }, ]); }); test("Syncs a post with a date-only published value", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); + const postId = ":test-post-uuid:"; - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -232,9 +193,8 @@ This is the post content. }, } as unknown as Job); - expect(insertPostDataValues).toBeCalledWith( + expect(db.insert(posts).values).toHaveBeenCalledWith( expect.objectContaining({ - slug: "date-only-post", title: "Date Only Post", publishedAt: new Date("2024-01-15"), }), @@ -242,30 +202,15 @@ This is the post content. }); test("Deletes a post record if it no longer exists", async () => { - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockImplementation( - () => - ({ - from: vi.fn((table: unknown) => ({ - where: vi.fn().mockResolvedValue( - table === postAttachments - ? [ - { - attachmentKey: "posts/example-post/attachments/notes.pdf", - }, - { - attachmentKey: "posts/example-post/attachments/banner.jpeg", - }, - ] - : [{ authorSlug: "example-author" }, { authorSlug: "co-author" }], - ), - })), - }) as never, - ); + vi.mocked( + db.delete(posts).where(expect.anything()).returning, + ).mockResolvedValue([{ id: ":deleted-post-id:" }]); + vi.mocked( + db.select(expect.anything()).from(postAuthors).where, + ).mockResolvedValue([ + { authorSlug: "example-author" }, + { authorSlug: "co-author" }, + ]); // Mock GitHub: return 404 vi.mocked(github.getContents).mockImplementation(((params: { @@ -289,27 +234,18 @@ test("Deletes a post record if it no longer exists", async () => { }, } as unknown as Job); - // Assert: Post's attachments were removed from S3 before the cascading delete - expect(s3.remove).toBeCalledWith( - "example-bucket", - "posts/example-post/attachments/notes.pdf", - ); - expect(s3.remove).toBeCalledWith( - "example-bucket", - "posts/example-post/attachments/banner.jpeg", - ); - // Assert: Post was deleted from posts table (cascade handles related tables) - expect(db.delete).toBeCalledWith(posts); - expect(deleteWhere).toBeCalledWith(eq(posts.slug, "example-post")); + expect(db.delete(posts).where).toHaveBeenCalledWith( + and(eq(posts.slug, "example-post"), eq(posts.branch, "main")), + ); // Assert: Achievements re-evaluated for the authors who were on the post - expect(createJob).toBeCalledWith( + expect(createJob).toHaveBeenCalledWith( Tasks.GRANT_AUTHOR_ACHIEVEMENTS, "grant-author-achievements:example-author", { profileSlug: "example-author" }, ); - expect(createJob).toBeCalledWith( + expect(createJob).toHaveBeenCalledWith( Tasks.GRANT_AUTHOR_ACHIEVEMENTS, "grant-author-achievements:co-author", { profileSlug: "co-author" }, @@ -317,37 +253,11 @@ test("Deletes a post record if it no longer exists", async () => { }); test("Links post to collection when collection is provided", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); + const postId = ":post-with-collection-uuid:"; - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); // Note: collection path format vi.mocked(github.getContents).mockImplementation(((params: { @@ -402,45 +312,27 @@ order: 1 } as unknown as Job); // Assert: Collection chapter was referenced - expect(insertPostsValues).toBeCalledWith({ - slug: "example-post", - collectionSlug: "example-collection", - collectionOrder: 1, - }); + expect(db.insert(posts).values).toHaveBeenCalledWith( + expect.objectContaining({ + slug: "example-post", + locale: "en", + branch: "main", + groupId: undefined, + collectionSlug: "example-collection", + collectionOrder: 1, + }), + ); }); test("Syncs post with multiple locales", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); + const postIdEn = ":multilang-post-en:"; + const postIdEs = ":multilang-post-es:"; - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning) + .mockResolvedValueOnce([{ id: postIdEn }]) + .mockResolvedValueOnce([{ id: postIdEs }]); // Return folder listing with both index.md and index.es.md vi.mocked(github.getContents).mockImplementation(((params: { @@ -503,14 +395,14 @@ published: "2024-01-15T00:00:00Z" } as unknown as Job); // Assert: Both locales were uploaded to S3 - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/multilang-post/en/content.md", undefined, expect.anything(), "text/markdown", ); - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/multilang-post/es/content.md", undefined, @@ -519,54 +411,28 @@ published: "2024-01-15T00:00:00Z" ); // Assert: Both locales were saved to database - expect(insertPostDataValues).toBeCalledWith( + expect(db.insert(posts).values).toHaveBeenCalledWith( expect.objectContaining({ slug: "multilang-post", - locale: "en", title: "English Post", + locale: "en", }), ); - expect(insertPostDataValues).toBeCalledWith( + expect(db.insert(posts).values).toHaveBeenCalledWith( expect.objectContaining({ slug: "multilang-post", - locale: "es", title: "Post en EspaƱol", + locale: "es", }), ); }); test("Handles post with multiple authors", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); + const postId = ":test-post-uuid:"; - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); vi.mocked(github.getContents).mockImplementation(((params: { path: string; @@ -612,48 +478,27 @@ authors: } as unknown as Job); // Assert: Both authors should be inserted (folder owner first, then co-author from frontmatter) - expect(insertPostAuthorsValues).toBeCalledWith([ + expect(db.insert(postAuthors).values).toHaveBeenCalledWith([ { - postSlug: "collab-post", + postId, authorSlug: "example-author", }, { - postSlug: "collab-post", + postId, authorSlug: "co-author", }, ]); }); -test("Unions tags across all locales", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostTagsValues = vi.fn(); +test("Differentiates tags between locales", async () => { + const postIdEn = ":multilang-post-en:"; + const postIdEs = ":multilang-post-es:"; - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postTags) { - return { values: insertPostTagsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning) + .mockResolvedValueOnce([{ id: postIdEn }]) + .mockResolvedValueOnce([{ id: postIdEs }]); // Return folder listing with both index.md and index.es.md vi.mocked(github.getContents).mockImplementation(((params: { @@ -723,59 +568,35 @@ tags: }, } as unknown as Job); - // Assert: Tags from both locales are unioned, with duplicates deduped - expect(insertPostTagsValues).toBeCalledWith([ + // Assert: Tags from each locale are correctly linked + expect(db.insert(postTags).values).toHaveBeenCalledWith([ { - postSlug: "multilang-tags-post", + postId: postIdEn, tag: "javascript", }, { - postSlug: "multilang-tags-post", + postId: postIdEn, tag: "tutorial", }, + ]); + + expect(db.insert(postTags).values).toHaveBeenCalledWith([ { - postSlug: "multilang-tags-post", + postId: postIdEs, + tag: "tutorial", + }, + { + postId: postIdEs, tag: "espanol", }, ]); }); test("Uploads post attachments, resizing images and content-addressing their keys by sha", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostAttachmentsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postAttachments) { - return { values: insertPostAttachmentsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + const postId = ":post-attachment-id:"; + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); const basePath = "/content/example-author/posts/attachment-post/"; const baseFolderPath = "content/example-author/posts/attachment-post/"; @@ -854,7 +675,7 @@ published: "2024-01-15T00:00:00Z" } as unknown as Job); // Assert: Non-image attachment uploaded as-is, keyed by its sha and original extension - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/attachment-post/attachments/notes-sha.pdf", undefined, @@ -863,7 +684,7 @@ published: "2024-01-15T00:00:00Z" ); // Assert: Image attachment resized and converted; key is the sha with a ".jpeg" extension - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/attachment-post/attachments/banner-sha.jpeg", undefined, @@ -874,62 +695,39 @@ published: "2024-01-15T00:00:00Z" // Assert: Attachment rows saved with resized image dimensions, null for non-images. // The 1x1 fixture is already smaller than the max size, so withoutEnlargement // keeps it at 1x1 instead of upscaling it. - expect(insertPostAttachmentsValues).toBeCalledWith([ + expect(db.insert(attachments).values).toHaveBeenCalledWith({ + attachmentKey: "posts/attachment-post/attachments/notes-sha.pdf", + sha: "notes-sha", + width: null, + height: null, + }); + expect(db.insert(attachments).values).toHaveBeenCalledWith({ + attachmentKey: "posts/attachment-post/attachments/banner-sha.jpeg", + sha: "banner-sha", + width: 1, + height: 1, + }); + expect(db.insert(attachments).values).toHaveBeenCalledTimes(2); + + expect(db.insert(postAttachments).values).toHaveBeenCalledExactlyOnceWith([ { - postSlug: "attachment-post", - attachmentName: "notes.pdf", + postId, attachmentKey: "posts/attachment-post/attachments/notes-sha.pdf", - sha: "notes-sha", - width: null, - height: null, + attachmentName: "notes.pdf", }, { - postSlug: "attachment-post", - attachmentName: "banner.png", + postId, attachmentKey: "posts/attachment-post/attachments/banner-sha.jpeg", - sha: "banner-sha", - width: 1, - height: 1, + attachmentName: "banner.png", }, ]); }); test("Passes attachment paths to GitHub unchanged, without URL-encoding special characters", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostAttachmentsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postAttachments) { - return { values: insertPostAttachmentsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockReturnValue({ - from: vi.fn().mockReturnValue({ - where: vi.fn().mockResolvedValue([]), - }), - } as never); + const postId = ":post-attachment-id:"; + vi.mocked(selectExistingAttachments).mockResolvedValue([]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); const basePath = "/content/example-author/posts/special-chars-post/"; const baseFolderPath = "content/example-author/posts/special-chars-post/"; @@ -999,79 +797,26 @@ published: "2024-01-15T00:00:00Z" // Assert: the raw entry path (with its space and "#" intact) was passed // straight through to getContentsRawStream, unencoded - expect(github.getContentsRawStream).toBeCalledWith( + expect(github.getContentsRawStream).toHaveBeenCalledWith( expect.objectContaining({ path: `${baseFolderPath}${attachmentName}` }), ); }); test("Diffs post attachments: skips unchanged sha, re-uploads changed sha under a new key, removes deleted", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostAttachmentsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postAttachments) { - return { values: insertPostAttachmentsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockImplementation( - () => - ({ - from: vi.fn((table: unknown) => ({ - where: vi.fn().mockResolvedValue( - table === postAttachments - ? [ - { - attachmentName: "old-file.txt", - attachmentKey: - "posts/diffing-post/attachments/old-file-sha.txt", - sha: "old-file-sha", - width: null, - height: null, - }, - { - attachmentName: "unchanged.txt", - attachmentKey: - "posts/diffing-post/attachments/unchanged-sha.txt", - sha: "unchanged-sha", - width: null, - height: null, - }, - { - attachmentName: "changed.txt", - attachmentKey: - "posts/diffing-post/attachments/old-changed-sha.txt", - sha: "old-changed-sha", - width: null, - height: null, - }, - ] - : [], - ), - })), - }) as never, - ); + const postId = ":post-attachment-id:"; + vi.mocked(selectExistingAttachments).mockResolvedValue([ + { + attachmentKey: "posts/diffing-post/attachments/old-file-sha.txt", + }, + { + attachmentKey: "posts/diffing-post/attachments/unchanged-sha.txt", + }, + { + attachmentKey: "posts/diffing-post/attachments/old-changed-sha.txt", + }, + ]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); const basePath = "/content/example-author/posts/diffing-post/"; const baseFolderPath = "content/example-author/posts/diffing-post/"; @@ -1143,19 +888,9 @@ published: "2024-01-15T00:00:00Z" }, } as unknown as Job); - // Assert: attachment no longer in the repo was removed from S3 - expect(s3.remove).toBeCalledWith( - "example-bucket", - "posts/diffing-post/attachments/old-file-sha.txt", - ); - // Assert: changed attachment's old sha-keyed object was removed, and the // new sha-keyed object was uploaded in its place - expect(s3.remove).toBeCalledWith( - "example-bucket", - "posts/diffing-post/attachments/old-changed-sha.txt", - ); - expect(s3.upload).toBeCalledWith( + expect(s3.upload).toHaveBeenCalledWith( "example-bucket", "posts/diffing-post/attachments/new-changed-sha.txt", undefined, @@ -1163,112 +898,47 @@ published: "2024-01-15T00:00:00Z" "text/plain", ); - // Assert: the new object is uploaded before the old one is removed, so a - // failed upload can't leave the persisted row pointing at a deleted key - const newKeyUploadOrder = vi - .mocked(s3.upload) - .mock.calls.findIndex( - (call) => - call[1] === "posts/diffing-post/attachments/new-changed-sha.txt", - ); - const oldKeyRemoveOrder = vi - .mocked(s3.remove) - .mock.calls.findIndex( - (call) => - call[1] === "posts/diffing-post/attachments/old-changed-sha.txt", - ); - expect( - vi.mocked(s3.upload).mock.invocationCallOrder[newKeyUploadOrder], - ).toBeLessThan( - vi.mocked(s3.remove).mock.invocationCallOrder[oldKeyRemoveOrder], - ); - // Assert: unchanged attachment was NOT re-uploaded or removed - expect(s3.upload).not.toBeCalledWith( + expect(s3.upload).not.toHaveBeenCalledWith( "example-bucket", "posts/diffing-post/attachments/unchanged-sha.txt", undefined, expect.anything(), expect.anything(), ); - expect(s3.remove).not.toBeCalledWith( - "example-bucket", - "posts/diffing-post/attachments/unchanged-sha.txt", - ); // Assert: only the two attachments still present in the repo are saved - expect(insertPostAttachmentsValues).toBeCalledWith([ + expect(db.insert(attachments).values).toHaveBeenCalledWith({ + attachmentKey: "posts/diffing-post/attachments/new-changed-sha.txt", + sha: "new-changed-sha", + width: null, + height: null, + }); + expect(db.insert(attachments).values).toHaveBeenCalledTimes(1); + + expect(db.insert(postAttachments).values).toHaveBeenCalledExactlyOnceWith([ { - postSlug: "diffing-post", - attachmentName: "unchanged.txt", attachmentKey: "posts/diffing-post/attachments/unchanged-sha.txt", - sha: "unchanged-sha", - width: null, - height: null, + attachmentName: "unchanged.txt", + postId, }, { - postSlug: "diffing-post", - attachmentName: "changed.txt", attachmentKey: "posts/diffing-post/attachments/new-changed-sha.txt", - sha: "new-changed-sha", - width: null, - height: null, + attachmentName: "changed.txt", + postId, }, ]); }); test("Skips an attachment entirely when its sha matches the stored value", async () => { - const insertPostsValues = vi.fn().mockReturnValue({ - onConflictDoNothing: vi.fn(), - }); - const insertPostDataValues = vi.fn().mockReturnValue({ - onConflictDoUpdate: vi.fn(), - }); - const insertPostAuthorsValues = vi.fn(); - const insertPostAttachmentsValues = vi.fn(); - - vi.mocked(db.insert).mockImplementation((table) => { - if (table === posts) { - return { values: insertPostsValues } as never; - } - if (table === postData) { - return { values: insertPostDataValues } as never; - } - if (table === postAuthors) { - return { values: insertPostAuthorsValues } as never; - } - if (table === postAttachments) { - return { values: insertPostAttachmentsValues } as never; - } - throw new Error(`Unexpected table: ${table}`); - }); - - const deleteWhere = vi.fn(); - vi.mocked(db.delete).mockReturnValue({ - where: deleteWhere, - } as never); - - vi.mocked(db.select).mockImplementation( - () => - ({ - from: vi.fn((table: unknown) => ({ - where: vi.fn().mockResolvedValue( - table === postAttachments - ? [ - { - attachmentName: "unchanged.txt", - attachmentKey: - "posts/skip-post/attachments/unchanged-sha.txt", - sha: "unchanged-sha", - width: null, - height: null, - }, - ] - : [], - ), - })), - }) as never, - ); + const postId = ":test-post-id:"; + vi.mocked(selectExistingAttachments).mockResolvedValue([ + { + attachmentKey: "posts/skip-post/attachments/unchanged-sha.txt", + }, + ]); + vi.mocked(selectPreviousAuthors).mockResolvedValue([]); + vi.mocked(insertPostReturning).mockResolvedValue([{ id: postId }]); const basePath = "/content/example-author/posts/skip-post/"; const baseFolderPath = "content/example-author/posts/skip-post/"; @@ -1324,30 +994,27 @@ published: "2024-01-15T00:00:00Z" // Assert: the attachment's content was never fetched from GitHub, since its // sha already matched the stored row - expect(github.getContentsRawStream).not.toBeCalledWith( + expect(github.getContentsRawStream).not.toHaveBeenCalledWith( expect.objectContaining({ path: `${baseFolderPath}unchanged.txt` }), ); // Assert: no S3 interaction happened for the attachment itself (content.md // is still uploaded separately as part of every sync) - expect(s3.upload).not.toBeCalledWith( + expect(s3.upload).not.toHaveBeenCalledWith( "example-bucket", "posts/skip-post/attachments/unchanged-sha.txt", expect.anything(), expect.anything(), expect.anything(), ); - expect(s3.remove).not.toBeCalled(); // Assert: the existing row was carried forward unchanged - expect(insertPostAttachmentsValues).toBeCalledWith([ + expect(db.insert(attachments).values).not.toHaveBeenCalled(); + expect(db.insert(postAttachments).values).toHaveBeenCalledExactlyOnceWith([ { - postSlug: "skip-post", attachmentName: "unchanged.txt", attachmentKey: "posts/skip-post/attachments/unchanged-sha.txt", - sha: "unchanged-sha", - width: null, - height: null, + postId, }, ]); }); diff --git a/apps/worker/src/tasks/sync-post/processor.ts b/apps/worker/src/tasks/sync-post/processor.ts index 2cf26c73..0ebb8a88 100644 --- a/apps/worker/src/tasks/sync-post/processor.ts +++ b/apps/worker/src/tasks/sync-post/processor.ts @@ -3,15 +3,16 @@ import { Tasks, createJob } from "@playfulprogramming/bullmq"; import { db, posts, - postData, postAuthors, postTags, postAttachments, + postGroups, + attachments, } from "@playfulprogramming/db"; import * as github from "@playfulprogramming/github-api"; import { s3 } from "@playfulprogramming/s3"; import { createProcessor } from "../../createProcessor.ts"; -import { eq } from "drizzle-orm"; +import { and, eq, isNotNull } from "drizzle-orm"; import matter from "gray-matter"; import { Value } from "typebox/value"; import sharp from "sharp"; @@ -100,12 +101,8 @@ function collectAttachmentEntries( } interface AttachmentRow { - postSlug: string; - attachmentName: string; attachmentKey: string; - sha: string; - width: number | null; - height: number | null; + attachmentName: string; } export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { @@ -137,27 +134,18 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { `Post ${post} (${basePath}) returned 404 - removing from database.`, ); - const removedAttachmentRows = await db - .select({ attachmentKey: postAttachments.attachmentKey }) - .from(postAttachments) - .where(eq(postAttachments.postSlug, post)); - - if (removedAttachmentRows.length > 0) { - const bucket = await s3.ensureBucket(env.S3_BUCKET); - await Promise.all( - removedAttachmentRows.map(async ({ attachmentKey }) => { - await s3.remove(bucket, attachmentKey); - console.log(`Removed attachment ${attachmentKey} from S3`); - }), - ); - } + const removedAuthorRows = await db.transaction(async (tx) => { + const removalFilter = and(eq(posts.slug, post), eq(posts.branch, ref)); - const removedAuthorRows = await db - .select({ authorSlug: postAuthors.authorSlug }) - .from(postAuthors) - .where(eq(postAuthors.postSlug, post)); + const removedAuthorRows = await tx + .select({ authorSlug: postAuthors.authorSlug }) + .from(postAuthors) + .innerJoin(posts, eq(posts.id, postAuthors.postId)) + .where(removalFilter); - await db.delete(posts).where(eq(posts.slug, post)); + await tx.delete(posts).where(removalFilter); + return removedAuthorRows; + }); for (const { authorSlug } of removedAuthorRows) { await createJob( @@ -194,9 +182,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { // ========================================================================= // Phase 1: Collect all data from GitHub // ========================================================================= - const allAuthorSlugs = new Set([author]); - const allTags = new Set(); - const localeData = await Promise.all( localeFiles.map(async (file) => { const locale = extractLocale(file.name); @@ -220,14 +205,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { const { data: frontmatter, content } = matter(rawMarkdown); const parsed = Value.Parse(PostMetaSchema, frontmatter); - if (parsed.authors) { - parsed.authors.forEach((a) => allAuthorSlugs.add(a)); - } - - if (parsed.tags) { - parsed.tags.forEach((t) => allTags.add(t)); - } - // If the description is missing, populate it from the content parsed.description ??= extractMarkdownExcerpt(content, 150); // calculate a (very) approximate word count @@ -237,9 +214,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { }), ); - const authorSlugs = [...allAuthorSlugs]; - const tags = [...allTags]; - // ========================================================================= // Phase 2: Upload all markdown to S3 // ========================================================================= @@ -262,53 +236,39 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { // ========================================================================= // Phase 3: Discover, resize, diff, and upload post attachments // ========================================================================= - const attachmentEntries = collectAttachmentEntries( - folderResponse.data.entries, - ); - - const previousAttachmentRows = await db - .select({ - attachmentName: postAttachments.attachmentName, - attachmentKey: postAttachments.attachmentKey, - sha: postAttachments.sha, - width: postAttachments.width, - height: postAttachments.height, - }) - .from(postAttachments) - .where(eq(postAttachments.postSlug, post)); - const previousAttachmentsByName = new Map( - previousAttachmentRows.map((row) => [row.attachmentName, row]), - ); - - const discoveredAttachmentNames = new Set( - attachmentEntries.map((entry) => entry.name), + const attachmentRows: AttachmentRow[] = []; + const existingAttachmentRecords = await db + .select({ attachmentKey: attachments.attachmentKey }) + .from(attachments) + .innerJoin( + postAttachments, + eq(postAttachments.attachmentKey, attachments.attachmentKey), + ) + .innerJoin(posts, eq(posts.id, postAttachments.postId)) + .where(eq(posts.slug, post)); + const existingAttachmentKeys = new Set( + existingAttachmentRecords.map(({ attachmentKey }) => attachmentKey), ); - for (const [attachmentName, row] of previousAttachmentsByName) { - if (!discoveredAttachmentNames.has(attachmentName)) { - await s3.remove(bucket, row.attachmentKey); - console.log( - `Removed attachment ${row.attachmentKey} from S3 (no longer in repo)`, - ); - } - } - - const attachmentRows: AttachmentRow[] = []; + for (const { name, path, sha } of collectAttachmentEntries( + folderResponse.data.entries, + )) { + const isImage = isImageAttachment(name); - for (const { name, path, sha } of attachmentEntries) { - const previous = previousAttachmentsByName.get(name); + // The key is derived from the file's sha, so a changed file always gets + // a brand-new key - no risk of collision. Upload the new object before + // removing the old one, so a failed upload doesn't leave the persisted + // row pointing at a key that no longer exists in S3. + const extension = isImage ? ".jpeg" : extname(name); + const attachmentKey = `posts/${post}/attachments/${sha}${extension}`; // Content-addressed keys mean an unchanged sha implies an unchanged // object in S3 - carry the existing row forward without touching GitHub // or S3 at all. - if (previous !== undefined && previous.sha === sha) { + if (existingAttachmentKeys.has(attachmentKey)) { attachmentRows.push({ - postSlug: post, + attachmentKey, attachmentName: name, - attachmentKey: previous.attachmentKey, - sha, - width: previous.width, - height: previous.height, }); continue; } @@ -325,7 +285,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { throw new Error(`Unable to fetch attachment ${name} for ${post}`); } - const isImage = isImageAttachment(name); let buffer: Buffer; let width: number | null = null; let height: number | null = null; @@ -339,13 +298,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { buffer = Buffer.from(await new Response(fileStream).arrayBuffer()); } - // The key is derived from the file's sha, so a changed file always gets - // a brand-new key - no risk of collision. Upload the new object before - // removing the old one, so a failed upload doesn't leave the persisted - // row pointing at a key that no longer exists in S3. - const extension = isImage ? ".jpeg" : extname(name); - const attachmentKey = `posts/${post}/attachments/${sha}${extension}`; - await s3.upload( bucket, attachmentKey, @@ -355,17 +307,19 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { ); console.log(`Uploaded attachment ${attachmentKey} to S3`); - if (previous !== undefined) { - await s3.remove(bucket, previous.attachmentKey); - } + await db + .insert(attachments) + .values({ + attachmentKey, + sha, + width, + height, + }) + .onConflictDoNothing(); attachmentRows.push({ - postSlug: post, - attachmentName: name, attachmentKey, - sha, - width, - height, + attachmentName: name, }); } @@ -374,26 +328,56 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { // ========================================================================= const previousAuthorRows = await db .select({ authorSlug: postAuthors.authorSlug }) - .from(postAuthors) - .where(eq(postAuthors.postSlug, post)); - const previousAuthorSlugs = previousAuthorRows.map((r) => r.authorSlug); + .from(posts) + .innerJoin(postAuthors, eq(posts.id, postAuthors.postId)) + .where(and(eq(posts.slug, post), eq(posts.branch, ref))); + const affectedAuthorSlugs = new Set( + previousAuthorRows.map((r) => r.authorSlug), + ); await db.transaction(async (tx) => { - await tx - .insert(posts) - .values({ - slug: post, - collectionSlug: collection, - collectionOrder: localeData[0]?.parsed?.order, - }) - .onConflictDoNothing(); - for (const { locale, parsed, wordCount } of localeData) { - const postDataRecord = { + let groupId: string | undefined; + if (parsed.upToDateSlug) { + const upToDatePosts = await tx + .select({ groupId: posts.groupId }) + .from(posts) + .where( + and(eq(posts.slug, parsed.upToDateSlug), isNotNull(posts.groupId)), + ) + .limit(1); + + groupId = upToDatePosts[0]?.groupId || undefined; + + if (!groupId) { + const [newGroup] = await tx + .insert(postGroups) + .values({}) + .returning({ id: postGroups.id }); + groupId = newGroup.id; + } + } + + // 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, + branch: ref, + groupId, + collectionSlug: collection, + collectionOrder: localeData[0]?.parsed?.order, + versionName: parsed.version, title: parsed.title, - version: parsed.version, description: parsed.description, wordCount, socialImage: parsed.socialImg ?? null, @@ -409,41 +393,47 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { }, }; - await tx - .insert(postData) - .values(postDataRecord) - .onConflictDoUpdate({ - target: [postData.slug, postData.locale, postData.version], - set: postDataRecord, - }); - - console.log(`Saved post metadata for ${post} (${locale})`); - } - - await tx.delete(postAuthors).where(eq(postAuthors.postSlug, post)); - - await tx.insert(postAuthors).values( - authorSlugs.map((authorSlug) => ({ - postSlug: post, - authorSlug, - })), - ); + const [postRecord] = await tx + .insert(posts) + .values(postValues) + .returning({ id: posts.id }); - await tx.delete(postTags).where(eq(postTags.postSlug, post)); + const authorSlugs = new Set([author, ...(parsed.authors ?? [])]); + parsed.authors?.forEach((authorSlug) => + affectedAuthorSlugs.add(authorSlug), + ); - if (tags.length > 0) { - await tx.insert(postTags).values( - tags.map((tag) => ({ - postSlug: post, - tag, - })), + await tx.insert(postAuthors).values( + Array.from( + authorSlugs.values().map((authorSlug) => ({ + postId: postRecord.id, + authorSlug, + })), + ), ); - } - await tx.delete(postAttachments).where(eq(postAttachments.postSlug, post)); + if (parsed.tags && parsed.tags.length > 0) { + await tx.insert(postTags).values( + parsed.tags.map((tag) => ({ + postId: postRecord.id, + tag, + })), + ); + } + + if (attachmentRows.length > 0) { + await tx.insert(postAttachments).values( + Array.from( + attachmentRows.values().map((row) => ({ + postId: postRecord.id, + attachmentKey: row.attachmentKey, + attachmentName: row.attachmentName, + })), + ), + ); + } - if (attachmentRows.length > 0) { - await tx.insert(postAttachments).values(attachmentRows); + console.log(`Saved post metadata for ${post} (${locale})`); } }); @@ -451,10 +441,6 @@ export default createProcessor(Tasks.SYNC_POST, async (job, { signal }) => { // authors removed from the frontmatter so their stats are recomputed too. // createJob deduplicates by key, so concurrent post syncs for the same // author collapse into a single achievements job. - const affectedAuthorSlugs = [ - ...new Set([...authorSlugs, ...previousAuthorSlugs]), - ]; - for (const authorSlug of affectedAuthorSlugs) { await createJob( Tasks.GRANT_AUTHOR_ACHIEVEMENTS, diff --git a/apps/worker/test-utils/setup.ts b/apps/worker/test-utils/setup.ts index f7c9052c..b98f9978 100644 --- a/apps/worker/test-utils/setup.ts +++ b/apps/worker/test-utils/setup.ts @@ -43,8 +43,16 @@ vi.mock("@playfulprogramming/s3", () => { vi.mock("@playfulprogramming/db", () => { const insertMap = new Map(); const insertMockResponse = () => { - const onConflictDoUpdate = vi.fn(); - return { values: vi.fn(() => ({ onConflictDoUpdate })) }; + const returning = vi.fn(); + const onConflictDoNothing = vi.fn(() => ({ returning })); + const onConflictDoUpdate = vi.fn(() => ({ returning })); + return { + values: vi.fn(() => ({ + returning, + onConflictDoNothing, + onConflictDoUpdate, + })), + }; }; const deleteMap = new Map(); @@ -53,6 +61,14 @@ vi.mock("@playfulprogramming/db", () => { return { where: vi.fn(() => ({ returning })) }; }; + const selectMap = new Map(); + const selectMockResponse = () => { + const limit = vi.fn(); + const where = vi.fn(() => ({ limit })); + const innerJoin = vi.fn(() => ({ where, innerJoin })); + return { innerJoin, where }; + }; + const db = { insert: vi.fn((arg) => { return ( @@ -64,7 +80,14 @@ vi.mock("@playfulprogramming/db", () => { deleteMap.get(arg) ?? deleteMap.set(arg, deleteMockResponse()).get(arg) ); }), - select: vi.fn(), + select: vi.fn(() => ({ + from: vi.fn((arg) => { + return ( + selectMap.get(arg) ?? + selectMap.set(arg, selectMockResponse()).get(arg) + ); + }), + })), transaction: vi.fn((cb: (tx: unknown) => unknown) => cb(db)), }; @@ -81,7 +104,10 @@ vi.mock("@playfulprogramming/db", () => { role: {}, }, posts: { + id: {}, slug: {}, + locale: {}, + branch: {}, }, collections: { slug: {}, @@ -98,22 +124,22 @@ vi.mock("@playfulprogramming/db", () => { collectionSlug: {}, tag: {}, }, - postData: { - slug: {}, - locale: {}, - version: {}, - }, postAuthors: { - postSlug: {}, + postId: {}, authorSlug: {}, }, postTags: { - postSlug: {}, + postId: {}, tag: {}, }, postAttachments: { - postSlug: {}, + postId: {}, + attachmentKey: {}, + }, + attachments: { + attachmentKey: {}, attachmentName: {}, + sha: {}, }, collectionChapters: { postSlug: {}, diff --git a/packages/db/drizzle/20260711154425_odd_silver_centurion/migration.sql b/packages/db/drizzle/20260711154425_odd_silver_centurion/migration.sql new file mode 100644 index 00000000..dc408635 --- /dev/null +++ b/packages/db/drizzle/20260711154425_odd_silver_centurion/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE "attachments" ( + "attachment_key" text PRIMARY KEY, + "sha" text NOT NULL, + "width" integer, + "height" integer +); +--> statement-breakpoint +ALTER TABLE "post_authors" DROP CONSTRAINT "post_authors_post_slug_posts_slug_fk";--> statement-breakpoint +ALTER TABLE "post_data" DROP CONSTRAINT "post_data_slug_posts_slug_fk";--> statement-breakpoint +ALTER TABLE "post_tags" DROP CONSTRAINT "post_tags_post_slug_posts_slug_fkey";--> statement-breakpoint +ALTER TABLE "post_attachments" DROP CONSTRAINT "post_attachments_post_slug_posts_slug_fkey";--> statement-breakpoint +DROP TABLE "post_authors";--> statement-breakpoint +DROP TABLE "post_data";--> statement-breakpoint +DROP TABLE "posts";--> statement-breakpoint +DROP TABLE "post_tags";--> statement-breakpoint +DROP TABLE "post_attachments";--> statement-breakpoint diff --git a/packages/db/drizzle/20260711154425_odd_silver_centurion/snapshot.json b/packages/db/drizzle/20260711154425_odd_silver_centurion/snapshot.json new file mode 100644 index 00000000..7d5465df --- /dev/null +++ b/packages/db/drizzle/20260711154425_odd_silver_centurion/snapshot.json @@ -0,0 +1,1328 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "a4e68055-001b-4467-b2bb-3d1f2b56b72d", + "prevIds": [ + "21b57418-aed2-421c-96ea-46a68958ce8d", + "6e896762-9a78-46e0-b1dc-5fc6e15aa2ea" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "author_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "profile_achievements", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "profiles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_authors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_data", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "post_images", + "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" + }, + { + "isRlsEnabled": false, + "name": "attachments", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_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": "profile_slug", + "entityType": "columns", + "schema": "public", + "table": "profile_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "achievement_id", + "entityType": "columns", + "schema": "public", + "table": "profile_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": "profile_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_image", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "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": "profiles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_slug", + "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_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "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": "collection_data" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_image", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "social_image", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_slug", + "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "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": "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" + }, + { + "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" + }, + { + "nameExplicit": false, + "columns": [ + "profile_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "author_roles_profile_slug_profiles_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "author_roles" + }, + { + "nameExplicit": false, + "columns": [ + "profile_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "profile_achievements_profile_slug_profiles_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "profile_achievements" + }, + { + "nameExplicit": false, + "columns": [ + "collection_slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_collection_slug_collections_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "author_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_author_slug_profiles_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_data_slug_collections_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_data" + }, + { + "nameExplicit": false, + "columns": [ + "collection_slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_tags_collection_slug_collections_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_tags" + }, + { + "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": [ + "profile_slug", + "role" + ], + "nameExplicit": false, + "name": "author_roles_pkey", + "entityType": "pks", + "schema": "public", + "table": "author_roles" + }, + { + "columns": [ + "profile_slug", + "achievement_id" + ], + "nameExplicit": false, + "name": "profile_achievements_pkey", + "entityType": "pks", + "schema": "public", + "table": "profile_achievements" + }, + { + "columns": [ + "collection_slug", + "author_slug" + ], + "nameExplicit": false, + "name": "collection_authors_collection_slug_author_slug_pk", + "entityType": "pks", + "schema": "public", + "table": "collection_authors" + }, + { + "columns": [ + "slug", + "locale" + ], + "nameExplicit": false, + "name": "collection_data_slug_locale_pk", + "entityType": "pks", + "schema": "public", + "table": "collection_data" + }, + { + "columns": [ + "collection_slug", + "tag" + ], + "nameExplicit": false, + "name": "collection_tags_pkey", + "entityType": "pks", + "schema": "public", + "table": "collection_tags" + }, + { + "columns": [ + "gist_id", + "filename" + ], + "nameExplicit": false, + "name": "url_metadata_gist_file_pkey", + "entityType": "pks", + "schema": "public", + "table": "url_metadata_gist_file" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "profiles_pkey", + "schema": "public", + "table": "profiles", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "post_images_pkey", + "schema": "public", + "table": "post_images", + "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" + }, + { + "columns": [ + "attachment_key" + ], + "nameExplicit": false, + "name": "attachments_pkey", + "schema": "public", + "table": "attachments", + "entityType": "pks" + } + ], + "renames": [] +} \ No newline at end of file diff --git a/packages/db/drizzle/20260711154730_confused_green_goblin/migration.sql b/packages/db/drizzle/20260711154730_confused_green_goblin/migration.sql new file mode 100644 index 00000000..513ee2ac --- /dev/null +++ b/packages/db/drizzle/20260711154730_confused_green_goblin/migration.sql @@ -0,0 +1,53 @@ +CREATE TABLE "post_attachments" ( + "post_id" uuid, + "attachment_key" text, + "attachment_name" text NOT NULL, + CONSTRAINT "post_attachments_pkey" PRIMARY KEY("post_id","attachment_key") +); +--> statement-breakpoint +CREATE TABLE "post_authors" ( + "post_id" uuid, + "author_slug" text, + CONSTRAINT "post_authors_pkey" PRIMARY KEY("post_id","author_slug") +); +--> statement-breakpoint +CREATE TABLE "post_groups" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() +); +--> statement-breakpoint +CREATE TABLE "post_tags" ( + "post_id" uuid, + "tag" text, + CONSTRAINT "post_tags_pkey" PRIMARY KEY("post_id","tag") +); +--> statement-breakpoint +CREATE TABLE "posts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "slug" text NOT NULL, + "locale" text NOT NULL, + "branch" text NOT NULL, + "collection_slug" text, + "collection_order" integer DEFAULT 0 NOT NULL, + "group_id" uuid, + "version_name" text DEFAULT '' NOT NULL, + "version_order" integer DEFAULT 0 NOT NULL, + "title" text NOT NULL, + "description" text DEFAULT '' NOT NULL, + "word_count" integer DEFAULT 0 NOT NULL, + "social_image" text, + "banner_image" text, + "original_link" text, + "noindex" boolean DEFAULT false NOT NULL, + "edited_at" timestamp with time zone, + "published_at" timestamp with time zone, + "meta" jsonb NOT NULL, + CONSTRAINT "posts_slug_locale_branch_unique" UNIQUE("slug","locale","branch") +); +--> statement-breakpoint +ALTER TABLE "post_attachments" ADD CONSTRAINT "post_attachments_post_id_posts_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "post_attachments" ADD CONSTRAINT "post_attachments_attachment_key_attachments_attachment_key_fkey" FOREIGN KEY ("attachment_key") REFERENCES "attachments"("attachment_key") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "post_authors" ADD CONSTRAINT "post_authors_post_id_posts_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "post_authors" ADD CONSTRAINT "post_authors_author_slug_profiles_slug_fkey" FOREIGN KEY ("author_slug") REFERENCES "profiles"("slug") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_post_id_posts_id_fkey" FOREIGN KEY ("post_id") REFERENCES "posts"("id") ON DELETE CASCADE;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_collection_slug_collections_slug_fkey" FOREIGN KEY ("collection_slug") REFERENCES "collections"("slug") ON DELETE SET NULL;--> statement-breakpoint +ALTER TABLE "posts" ADD CONSTRAINT "posts_group_id_post_groups_id_fkey" FOREIGN KEY ("group_id") REFERENCES "post_groups"("id") ON DELETE CASCADE; \ No newline at end of file diff --git a/packages/db/drizzle/20260711154730_confused_green_goblin/snapshot.json b/packages/db/drizzle/20260711154730_confused_green_goblin/snapshot.json new file mode 100644 index 00000000..9ddee127 --- /dev/null +++ b/packages/db/drizzle/20260711154730_confused_green_goblin/snapshot.json @@ -0,0 +1,1893 @@ +{ + "version": "8", + "dialect": "postgres", + "id": "d3fee9ff-760a-4a38-bfc5-e8a035bb0a0e", + "prevIds": [ + "a4e68055-001b-4467-b2bb-3d1f2b56b72d" + ], + "ddl": [ + { + "isRlsEnabled": false, + "name": "author_roles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "profile_achievements", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "profiles", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_authors", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_data", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collection_tags", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "collections", + "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": "post_images", + "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" + }, + { + "isRlsEnabled": false, + "name": "attachments", + "entityType": "tables", + "schema": "public" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_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": "profile_slug", + "entityType": "columns", + "schema": "public", + "table": "profile_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "achievement_id", + "entityType": "columns", + "schema": "public", + "table": "profile_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": "profile_achievements" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "name", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "profile_image", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "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": "profiles" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "profiles" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_slug", + "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_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "locale", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "title", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "''", + "generated": null, + "identity": null, + "name": "description", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "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": "collection_data" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "meta", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "cover_image", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "social_image", + "entityType": "columns", + "schema": "public", + "table": "collection_data" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "collection_slug", + "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": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "slug", + "entityType": "columns", + "schema": "public", + "table": "collections" + }, + { + "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": "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": "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" + }, + { + "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" + }, + { + "nameExplicit": false, + "columns": [ + "profile_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "author_roles_profile_slug_profiles_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "author_roles" + }, + { + "nameExplicit": false, + "columns": [ + "profile_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "profile_achievements_profile_slug_profiles_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "profile_achievements" + }, + { + "nameExplicit": false, + "columns": [ + "collection_slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_collection_slug_collections_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "author_slug" + ], + "schemaTo": "public", + "tableTo": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_authors_author_slug_profiles_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_authors" + }, + { + "nameExplicit": false, + "columns": [ + "slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_data_slug_collections_slug_fk", + "entityType": "fks", + "schema": "public", + "table": "collection_data" + }, + { + "nameExplicit": false, + "columns": [ + "collection_slug" + ], + "schemaTo": "public", + "tableTo": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "collection_tags_collection_slug_collections_slug_fkey", + "entityType": "fks", + "schema": "public", + "table": "collection_tags" + }, + { + "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": "profiles", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "CASCADE", + "name": "post_authors_author_slug_profiles_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": "collections", + "columnsTo": [ + "slug" + ], + "onUpdate": "NO ACTION", + "onDelete": "SET NULL", + "name": "posts_collection_slug_collections_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": [ + "profile_slug", + "role" + ], + "nameExplicit": false, + "name": "author_roles_pkey", + "entityType": "pks", + "schema": "public", + "table": "author_roles" + }, + { + "columns": [ + "profile_slug", + "achievement_id" + ], + "nameExplicit": false, + "name": "profile_achievements_pkey", + "entityType": "pks", + "schema": "public", + "table": "profile_achievements" + }, + { + "columns": [ + "collection_slug", + "author_slug" + ], + "nameExplicit": false, + "name": "collection_authors_collection_slug_author_slug_pk", + "entityType": "pks", + "schema": "public", + "table": "collection_authors" + }, + { + "columns": [ + "slug", + "locale" + ], + "nameExplicit": false, + "name": "collection_data_slug_locale_pk", + "entityType": "pks", + "schema": "public", + "table": "collection_data" + }, + { + "columns": [ + "collection_slug", + "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": [ + "slug" + ], + "nameExplicit": false, + "name": "profiles_pkey", + "schema": "public", + "table": "profiles", + "entityType": "pks" + }, + { + "columns": [ + "slug" + ], + "nameExplicit": false, + "name": "collections_pkey", + "schema": "public", + "table": "collections", + "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": [ + "slug" + ], + "nameExplicit": false, + "name": "post_images_pkey", + "schema": "public", + "table": "post_images", + "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" + }, + { + "columns": [ + "attachment_key" + ], + "nameExplicit": false, + "name": "attachments_pkey", + "schema": "public", + "table": "attachments", + "entityType": "pks" + }, + { + "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/package.json b/packages/db/package.json index 559d3b4b..7d12cf13 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -7,6 +7,7 @@ "scripts": { "test:eslint": "eslint ./src", "test:build": "publint --strict", + "test": "drizzle-kit check", "drizzle:push": "drizzle-kit push", "drizzle:generate": "drizzle-kit generate", "build": "tsc --noEmit" diff --git a/packages/db/src/relations.ts b/packages/db/src/relations.ts index f9ae5fc2..29efc562 100644 --- a/packages/db/src/relations.ts +++ b/packages/db/src/relations.ts @@ -29,25 +29,25 @@ export const relations = defineRelations(schema, (r) => ({ // Posts relations posts: { - data: r.many.postData({ - from: r.posts.slug, - to: r.postData.slug, - }), authors: r.many.profiles({ - from: r.posts.slug.through(r.postAuthors.postSlug), + from: r.posts.id.through(r.postAuthors.postId), to: r.profiles.slug.through(r.postAuthors.authorSlug), }), collection: r.one.collections({ from: r.posts.collectionSlug, to: r.collections.slug, }), + tags: r.many.postTags({ + from: r.posts.id, + to: r.postTags.postId, + }), }, // Posts authors junction postAuthors: { post: r.one.posts({ - from: r.postAuthors.postSlug, - to: r.posts.slug, + from: r.postAuthors.postId, + to: r.posts.id, }), author: r.one.profiles({ from: r.postAuthors.authorSlug, @@ -59,7 +59,7 @@ export const relations = defineRelations(schema, (r) => ({ profiles: { postsAuthored: r.many.posts({ from: r.profiles.slug.through(r.postAuthors.authorSlug), - to: r.posts.slug.through(r.postAuthors.postSlug), + to: r.posts.id.through(r.postAuthors.postId), }), collectionsAuthored: r.many.collections({ from: r.profiles.slug.through(r.collectionAuthors.authorSlug), diff --git a/packages/db/src/schema/attachments.ts b/packages/db/src/schema/attachments.ts new file mode 100644 index 00000000..bcfba9c3 --- /dev/null +++ b/packages/db/src/schema/attachments.ts @@ -0,0 +1,8 @@ +import { pgTable, text, integer } from "drizzle-orm/pg-core"; + +export const attachments = pgTable("attachments", { + attachmentKey: text("attachment_key").primaryKey(), + sha: text("sha").notNull(), + width: integer("width"), + height: integer("height"), +}); diff --git a/packages/db/src/schema/collections.ts b/packages/db/src/schema/collections.ts index 2a58e7c3..685a5a1d 100644 --- a/packages/db/src/schema/collections.ts +++ b/packages/db/src/schema/collections.ts @@ -45,20 +45,17 @@ export const collectionAuthors = pgTable( ], ); -/** - * Query via: - * @example - * const collectionWithAuthors = await db.query.collections.findFirst({ - * where: (collections, { and, eq }) => and( - * eq(collections.slug, "my-collection-slug"), - * eq(collections.locale, "en") - * ), - * with: { - * authors: { - * with: { - * author: true, - * }, - * }, - * }, - * }); - */ +export const collectionTags = pgTable( + "collection_tags", + { + collectionSlug: text("collection_slug") + .notNull() + .references(() => collections.slug, { onDelete: "cascade" }), + tag: text("tag").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.collectionSlug, table.tag], + }), + ], +); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index a30d9052..6e4d89b0 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -3,5 +3,4 @@ export * from "./collections.ts"; export * from "./posts.ts"; export * from "./post-images.ts"; export * from "./url-metadata.ts"; -export * from "./tags.ts"; -export * from "./post-attachments.ts"; +export * from "./attachments.ts"; diff --git a/packages/db/src/schema/post-attachments.ts b/packages/db/src/schema/post-attachments.ts deleted file mode 100644 index 55a01dbd..00000000 --- a/packages/db/src/schema/post-attachments.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { pgTable, text, integer, primaryKey } from "drizzle-orm/pg-core"; -import { posts } from "./posts.ts"; - -export const postAttachments = pgTable( - "post_attachments", - { - postSlug: text("post_slug") - .notNull() - .references(() => posts.slug, { - onDelete: "cascade", - }), - attachmentName: text("attachment_name").notNull(), - attachmentKey: text("attachment_key").notNull(), - sha: text("sha").notNull(), - width: integer("width"), - height: integer("height"), - }, - (table) => [ - primaryKey({ - columns: [table.postSlug, table.attachmentName], - }), - ], -); diff --git a/packages/db/src/schema/posts.ts b/packages/db/src/schema/posts.ts index bad8b2d0..af2cd572 100644 --- a/packages/db/src/schema/posts.ts +++ b/packages/db/src/schema/posts.ts @@ -6,29 +6,34 @@ import { primaryKey, boolean, integer, + uuid, + unique, } from "drizzle-orm/pg-core"; import { profiles } from "./profiles.ts"; import { collections } from "./collections.ts"; +import { attachments } from "./attachments.ts"; -export const posts = pgTable("posts", { - slug: text("slug").primaryKey(), - collectionSlug: text("collection_slug").references(() => collections.slug, { - onDelete: "set null", - }), - collectionOrder: integer("collection_order").notNull().default(0), +export const postGroups = pgTable("post_groups", { + id: uuid("id").primaryKey().defaultRandom(), }); -export const postData = pgTable( - "post_data", +export const posts = pgTable( + "posts", { - slug: text("slug") - .notNull() - .references(() => posts.slug, { - onDelete: "cascade", - }), + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), locale: text("locale").notNull(), + branch: text("branch").notNull(), + collectionSlug: text("collection_slug").references(() => collections.slug, { + onDelete: "set null", + }), + collectionOrder: integer("collection_order").notNull().default(0), + groupId: uuid("group_id").references(() => postGroups.id, { + onDelete: "cascade", + }), + versionName: text("version_name").notNull().default(""), + versionOrder: integer("version_order").notNull().default(0), title: text("title").notNull(), - version: text("version").notNull().default(""), description: text("description").notNull().default(""), wordCount: integer("word_count").notNull().default(0), socialImage: text("social_image"), @@ -39,17 +44,15 @@ export const postData = pgTable( publishedAt: timestamp("published_at", { withTimezone: true }), meta: jsonb("meta").notNull(), }, - (table) => [ - primaryKey({ columns: [table.slug, table.locale, table.version] }), - ], + (table) => [unique().on(table.slug, table.locale, table.branch)], ); export const postAuthors = pgTable( "post_authors", { - postSlug: text("post_slug") + postId: uuid("post_id") .notNull() - .references(() => posts.slug, { + .references(() => posts.id, { onDelete: "cascade", }), authorSlug: text("author_slug") @@ -60,7 +63,46 @@ export const postAuthors = pgTable( }, (table) => [ primaryKey({ - columns: [table.postSlug, table.authorSlug], + columns: [table.postId, table.authorSlug], + }), + ], +); + +export const postTags = pgTable( + "post_tags", + { + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { + onDelete: "cascade", + }), + tag: text("tag").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.postId, table.tag], + }), + ], +); + +export const postAttachments = pgTable( + "post_attachments", + { + postId: uuid("post_id") + .notNull() + .references(() => posts.id, { + onDelete: "cascade", + }), + attachmentKey: text("attachment_key") + .notNull() + .references(() => attachments.attachmentKey, { + onDelete: "cascade", + }), + attachmentName: text("attachment_name").notNull(), + }, + (table) => [ + primaryKey({ + columns: [table.postId, table.attachmentKey], }), ], ); diff --git a/packages/db/src/schema/tags.ts b/packages/db/src/schema/tags.ts deleted file mode 100644 index 6ecbe4d7..00000000 --- a/packages/db/src/schema/tags.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { pgTable, text, primaryKey } from "drizzle-orm/pg-core"; -import { posts } from "./posts.ts"; -import { collections } from "./collections.ts"; - -export const postTags = pgTable( - "post_tags", - { - postSlug: text("post_slug") - .notNull() - .references(() => posts.slug, { - onDelete: "cascade", - }), - tag: text("tag").notNull(), - }, - (table) => [ - primaryKey({ - columns: [table.postSlug, table.tag], - }), - ], -); - -export const collectionTags = pgTable( - "collection_tags", - { - collectionSlug: text("collection_slug") - .notNull() - .references(() => collections.slug, { onDelete: "cascade" }), - tag: text("tag").notNull(), - }, - (table) => [ - primaryKey({ - columns: [table.collectionSlug, table.tag], - }), - ], -);