|
| 1 | +import { it, expect, beforeEach, afterEach } from "vitest"; |
| 2 | + |
| 3 | +import { handleContentCreate } from "../../src/api/index.js"; |
| 4 | +import { SchemaRegistry } from "../../src/schema/registry.js"; |
| 5 | +import { SeoRepository } from "../../src/database/repositories/seo.js"; |
| 6 | +import { emdashLoader } from "../../src/loader.js"; |
| 7 | +import { runWithContext } from "../../src/request-context.js"; |
| 8 | +import { |
| 9 | + describeEachDialect, |
| 10 | + setupForDialect, |
| 11 | + teardownForDialect, |
| 12 | + type DialectTestContext, |
| 13 | +} from "../utils/test-db.js"; |
| 14 | + |
| 15 | +/** |
| 16 | + * Regression test for #1600: loadEntry's SELECT shape on wide collections. |
| 17 | + * |
| 18 | + * When a per-collection `ec_*` table has many flat scalar columns (common when |
| 19 | + * porting from WordPress / ACF or other builders where every section is a |
| 20 | + * top-level field), the previous implementation did: |
| 21 | + * |
| 22 | + * SELECT c.*, <5 SEO alias columns> FROM ec_table c LEFT JOIN _emdash_seo s |
| 23 | + * |
| 24 | + * On Cloudflare D1 the per-query result-set column limit (~100) made this |
| 25 | + * fail with `D1_ERROR: too many columns in result set` for collections |
| 26 | + * around 95+ user columns. The loader's try/catch wrapped it as a generic |
| 27 | + * `Failed to load entry` error and the call site returned a silent `null`. |
| 28 | + * |
| 29 | + * The fix splits the query: fetch the row from the collection table without |
| 30 | + * a SEO join, then fetch SEO separately and fold it onto the row using the |
| 31 | + * same alias names extractSeo() reads. The result set stays bounded in width |
| 32 | + * regardless of how many fields the collection has. |
| 33 | + * |
| 34 | + * Run on both dialects to keep parity with loader-seo.test.ts. |
| 35 | + */ |
| 36 | +describeEachDialect("Loader on wide-schema collections (#1600)", (dialect) => { |
| 37 | + let ctx: DialectTestContext; |
| 38 | + let seoRepo: SeoRepository; |
| 39 | + const COLLECTION = "wide_collection"; |
| 40 | + const USER_FIELD_COUNT = 95; |
| 41 | + |
| 42 | + beforeEach(async () => { |
| 43 | + ctx = await setupForDialect(dialect); |
| 44 | + const registry = new SchemaRegistry(ctx.db); |
| 45 | + |
| 46 | + // Create a collection with SEO enabled and a large number of flat |
| 47 | + // scalar fields. 95 user fields + 14 system columns + 5 SEO aliases |
| 48 | + // would have been ~114 result-set columns under the old LEFT JOIN |
| 49 | + // shape, well past D1's per-query limit. |
| 50 | + await registry.createCollection({ |
| 51 | + slug: COLLECTION, |
| 52 | + label: "Wide Collection", |
| 53 | + labelSingular: "Wide Entry", |
| 54 | + }); |
| 55 | + await registry.createField(COLLECTION, { |
| 56 | + slug: "title", |
| 57 | + label: "Title", |
| 58 | + type: "string", |
| 59 | + }); |
| 60 | + for (let i = 1; i <= USER_FIELD_COUNT; i++) { |
| 61 | + await registry.createField(COLLECTION, { |
| 62 | + slug: `field_${i}`, |
| 63 | + label: `Field ${i}`, |
| 64 | + type: "string", |
| 65 | + }); |
| 66 | + } |
| 67 | + // Enable SEO so extractSeo() has somewhere to read from. |
| 68 | + await ctx.db |
| 69 | + .updateTable("_emdash_collections") |
| 70 | + .set({ has_seo: 1 }) |
| 71 | + .where("slug", "=", COLLECTION) |
| 72 | + .execute(); |
| 73 | + |
| 74 | + seoRepo = new SeoRepository(ctx.db); |
| 75 | + }); |
| 76 | + |
| 77 | + afterEach(async () => { |
| 78 | + await teardownForDialect(ctx); |
| 79 | + }); |
| 80 | + |
| 81 | + function load(idOrSlug: string) { |
| 82 | + const loader = emdashLoader(); |
| 83 | + return runWithContext({ db: ctx.db }, () => |
| 84 | + loader.loadEntry!({ filter: { type: COLLECTION, id: idOrSlug } }), |
| 85 | + ); |
| 86 | + } |
| 87 | + |
| 88 | + it("loads an entry from a collection with 95+ flat user columns", async () => { |
| 89 | + const data: Record<string, string> = { title: "Wide Entry" }; |
| 90 | + for (let i = 1; i <= USER_FIELD_COUNT; i++) { |
| 91 | + data[`field_${i}`] = `value-${i}`; |
| 92 | + } |
| 93 | + const result = await handleContentCreate(ctx.db, COLLECTION, { |
| 94 | + data, |
| 95 | + status: "published", |
| 96 | + }); |
| 97 | + if (!result.success) throw new Error("Failed to create entry"); |
| 98 | + const slug = result.data!.item.slug!; |
| 99 | + |
| 100 | + const loaded = await load(slug); |
| 101 | + |
| 102 | + expect(loaded).toBeDefined(); |
| 103 | + expect((loaded as { data: Record<string, unknown> }).data.title).toBe("Wide Entry"); |
| 104 | + // Spot-check a handful of user fields across the range. |
| 105 | + const loadedData = (loaded as { data: Record<string, unknown> }).data; |
| 106 | + expect(loadedData.field_1).toBe("value-1"); |
| 107 | + expect(loadedData.field_50).toBe("value-50"); |
| 108 | + expect(loadedData.field_95).toBe("value-95"); |
| 109 | + }); |
| 110 | + |
| 111 | + it("still attaches data.seo on wide collections (SEO follow-up query)", async () => { |
| 112 | + const data: Record<string, string> = { title: "Wide With SEO" }; |
| 113 | + for (let i = 1; i <= USER_FIELD_COUNT; i++) { |
| 114 | + data[`field_${i}`] = `value-${i}`; |
| 115 | + } |
| 116 | + const result = await handleContentCreate(ctx.db, COLLECTION, { |
| 117 | + data, |
| 118 | + status: "published", |
| 119 | + }); |
| 120 | + if (!result.success) throw new Error("Failed to create entry"); |
| 121 | + const item = result.data!.item; |
| 122 | + |
| 123 | + await seoRepo.upsert(COLLECTION, item.id, { |
| 124 | + noIndex: true, |
| 125 | + canonical: "https://example.com/wide", |
| 126 | + title: "Wide SEO Title", |
| 127 | + }); |
| 128 | + |
| 129 | + const loaded = await load(item.slug!); |
| 130 | + const loadedData = (loaded as { data: Record<string, unknown> }).data; |
| 131 | + const seo = loadedData.seo as Record<string, unknown> | undefined; |
| 132 | + |
| 133 | + expect(seo).toBeDefined(); |
| 134 | + expect(seo!.noIndex).toBe(true); |
| 135 | + expect(seo!.canonical).toBe("https://example.com/wide"); |
| 136 | + expect(seo!.title).toBe("Wide SEO Title"); |
| 137 | + }); |
| 138 | + |
| 139 | + it("omits data.seo when no SEO row exists, even on wide collections", async () => { |
| 140 | + const data: Record<string, string> = { title: "No SEO" }; |
| 141 | + for (let i = 1; i <= USER_FIELD_COUNT; i++) { |
| 142 | + data[`field_${i}`] = `value-${i}`; |
| 143 | + } |
| 144 | + const result = await handleContentCreate(ctx.db, COLLECTION, { |
| 145 | + data, |
| 146 | + status: "published", |
| 147 | + }); |
| 148 | + if (!result.success) throw new Error("Failed to create entry"); |
| 149 | + const slug = result.data!.item.slug!; |
| 150 | + |
| 151 | + const loaded = await load(slug); |
| 152 | + const loadedData = (loaded as { data: Record<string, unknown> }).data; |
| 153 | + |
| 154 | + expect(loadedData.seo).toBeUndefined(); |
| 155 | + }); |
| 156 | +}); |
0 commit comments