Compare commits

...
Author SHA1 Message Date
TudorandClaude Opus 5 b0c7777e7a fix(cms): ship the initial migration so a container finds its tables
Staging failed on boot with 42P01, relation "payload.users" does not
exist. The schema was empty because no migration existed, and the
adapter cannot create tables itself: db-postgres/connect.js gates push
on NODE_ENV !== 'production', so it is inert in a deployed container
regardless of config.

The generated migration is schema-qualified to "payload" throughout but
does not create that schema — schemaName says where tables go, it does
not create anything. It only worked against the throwaway database used
to generate it because the schema was created there by hand, so every
real environment would have failed on the first statement. CREATE SCHEMA
IF NOT EXISTS is hand-added at the top of up(), which makes it exactly
the kind of edit a regeneration discards silently; a test asserts it is
present and ordered before the first CREATE TABLE.

payload-types.ts is now committed rather than ignored. Ignoring it meant
CI typechecked against looser types than a developer with a generated
copy, which is how a Record<string, unknown> cast passed CI and then
failed locally the moment the file appeared. The post page uses the
generated Post and Media types instead, and narrows heroImage rather
than asserting it, since the field is an id at shallow depth.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 18:31:38 +01:00
TudorandClaude Opus 5 a31d98b086 style(copy): remove em dashes from the site's prose
The em dash is one of the clearest tells of machine-written text, which
is the exact impression this work exists to remove. Rewritten rather
than substituted: where a dash was carrying a real aside the sentence is
split or recast, not patched with a comma.

Covers the About page, the two Callout labels an editor sees in the
admin panel, and PUBLISHING.md, which defines the house style and should
follow it. The rule is now recorded in that house style and in the
spec's voice rules, so it survives this branch.

Code comments are left alone: they are not copy, and the surrounding
codebase uses the same punctuation throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017YmbBhr8s7GusjDE12hrZM
2026-09-02 17:14:20 +01:00
12 changed files with 2349 additions and 45 deletions

No files matched your search

@@ -73,6 +73,8 @@ Applied to About and every post. Recorded here so the voice does not drift.
- State limits before someone else finds them. Every post that presents a - State limits before someone else finds them. Every post that presents a
metric says what it does not show. metric says what it does not show.
- No mission statements, no "passionate about", no invented team. - No mission statements, no "passionate about", no invented team.
- No em dashes. One of the clearest tells of machine-written prose, which is
the exact problem this work exists to fix.
- Short sentences. The existing code comments in this repo are already written - Short sentences. The existing code comments in this repo are already written
this way; the prose should match. this way; the prose should match.
-2
View File
@@ -40,5 +40,3 @@ yarn-error.log*
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts
# Payload generates this from the config on every build
/payload-types.ts
@@ -0,0 +1,52 @@
/**
* The generated migration is schema-qualified to "payload" throughout but does
* not create that schema — `schemaName` says where tables go, it does not
* create anything. On staging and production, which have never run it, the
* whole migration fails with `schema "payload" does not exist`.
*
* The CREATE SCHEMA is therefore hand-added, which makes it exactly the kind
* of edit a regeneration silently discards. This is the guard.
*/
import fs from 'fs';
import path from 'path';
const DIR = path.join(__dirname, '..', '..', 'migrations');
function migrationFiles() {
return fs
.readdirSync(DIR)
.filter((f) => f.endsWith('.ts') && f !== 'index.ts');
}
describe('payload migrations', () => {
it('ships at least one migration, so a container has tables to find', () => {
expect(migrationFiles().length).toBeGreaterThan(0);
});
it('creates the payload schema before creating anything in it', () => {
const initial = migrationFiles().find((f) => f.includes('initial'))!;
const sql = fs.readFileSync(path.join(DIR, initial), 'utf8');
expect(sql).toMatch(/CREATE SCHEMA IF NOT EXISTS "payload"/);
// Ordering matters: the schema must be created before the first object
// that lives in it, or the migration fails on its first statement.
expect(sql.indexOf('CREATE SCHEMA IF NOT EXISTS "payload"'))
.toBeLessThan(sql.indexOf('CREATE TABLE "payload"'));
});
it('creates the tables the app queries on boot', () => {
const initial = migrationFiles().find((f) => f.includes('initial'))!;
const sql = fs.readFileSync(path.join(DIR, initial), 'utf8');
for (const table of ['users', 'posts', '_posts_v', 'media', 'payload_migrations']) {
expect(sql).toContain(`CREATE TABLE "payload"."${table}"`);
}
});
it('is wired into the adapter, so it runs on server init', () => {
const config = fs.readFileSync(
path.join(__dirname, '..', '..', 'payload.config.ts'), 'utf8',
);
expect(config).toMatch(/prodMigrations:\s*migrations/);
});
});
+12 -11
View File
@@ -42,7 +42,7 @@ export default function AboutPage() {
<div className={styles.prose}> <div className={styles.prose}>
<p className={styles.lede}> <p className={styles.lede}>
I&apos;m a parent in south-west London. When we started looking at I&apos;m a parent in south-west London. When we started looking at
primary schools, I found the information I needed was all published — primary schools, I found the information I needed was all published,
and almost impossible to hold in one place. and almost impossible to hold in one place.
</p> </p>
@@ -68,11 +68,11 @@ export default function AboutPage() {
</p> </p>
<p> <p>
What I do have is the problem itself — I&apos;m going through primary What I do have is the problem itself. I&apos;m going through primary
admissions right now — and a working knowledge of data, which is what admissions right now, and I work with data for a living. That
I do for a living. That combination is enough to take published combination is enough to take published figures and present them
figures and present them honestly. It is not enough to tell you which honestly. It is not enough to tell you which school is right for your
school is right for your child, and this site never tries to. child, and this site never tries to.
</p> </p>
<h2 className={styles.subheading}>Where the numbers come from</h2> <h2 className={styles.subheading}>Where the numbers come from</h2>
@@ -96,9 +96,10 @@ export default function AboutPage() {
<p> <p>
A school is not its results. The figures here describe one year group, A school is not its results. The figures here describe one year group,
on a handful of days, measured in a way that suits national statistics on a handful of days, measured in a way that suits national statistics
rather than your child. A small cohort makes percentages swing wildly rather than your child. A small cohort makes percentages swing wildly.
— in a class of thirty, one pupil is more than three points. Results In a class of thirty, one pupil is worth more than three points.
say nothing at all about whether a child will be happy somewhere. Results say nothing at all about whether a child will be happy
somewhere.
</p> </p>
<p> <p>
@@ -113,8 +114,8 @@ export default function AboutPage() {
<h2 className={styles.subheading}>If something&apos;s wrong</h2> <h2 className={styles.subheading}>If something&apos;s wrong</h2>
<p> <p>
Tell me and I&apos;ll fix it. Genuinely — if a figure looks wrong, or Tell me and I&apos;ll fix it. If a figure looks wrong, or a page gives
a page gives a misleading impression of a school, I want to know. a misleading impression of a school, I genuinely want to know.
It&apos;s the fastest way this gets better. It&apos;s the fastest way this gets better.
</p> </p>
+24 -19
View File
@@ -5,6 +5,7 @@ import { notFound } from 'next/navigation';
import { RichText } from '@payloadcms/richtext-lexical/react'; import { RichText } from '@payloadcms/richtext-lexical/react';
import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react'; import type { JSXConvertersFunction } from '@payloadcms/richtext-lexical/react';
import { getCachedPayload } from '@/lib/payload'; import { getCachedPayload } from '@/lib/payload';
import type { Post, Media } from '@/payload-types';
import { absoluteUrl } from '@/lib/site'; import { absoluteUrl } from '@/lib/site';
import { import {
blogPostingJsonLd, blogPostingJsonLd,
@@ -24,11 +25,15 @@ import styles from './Post.module.css';
*/ */
export const revalidate = 3600; export const revalidate = 3600;
interface HeroImage { /**
url?: string | null; * heroImage is `number | Media | null`: an id when the query is shallow, the
alt?: string | null; * populated document at depth 1. Both pages query at depth 1, but narrowing
width?: number | null; * rather than asserting keeps it correct if that ever changes.
height?: number | null; */
function heroOf(post: Post): Media | null {
return typeof post.heroImage === 'object' && post.heroImage !== null
? post.heroImage
: null;
} }
/** /**
@@ -68,12 +73,12 @@ const findPost = cache(async (slug: string) => {
return docs[0] ?? null; return docs[0] ?? null;
}); });
function summarise(post: Record<string, unknown>) { function summarise(post: Post) {
return { return {
title: String(post.title), title: post.title,
slug: String(post.slug), slug: post.slug,
excerpt: String(post.excerpt), excerpt: post.excerpt,
publishedAt: String(post.publishedAt), publishedAt: post.publishedAt,
}; };
} }
@@ -84,18 +89,18 @@ export async function generateMetadata(
const post = await findPost(slug); const post = await findPost(slug);
if (!post) return { title: 'Not found' }; if (!post) return { title: 'Not found' };
const hero = post.heroImage as HeroImage | null | undefined; const hero = heroOf(post);
return { return {
title: String(post.title), title: post.title,
description: String(post.excerpt), description: post.excerpt,
alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) }, alternates: { canonical: absoluteUrl(`/blog/${post.slug}`) },
openGraph: { openGraph: {
type: 'article', type: 'article',
title: String(post.title), title: post.title,
description: String(post.excerpt), description: post.excerpt,
url: absoluteUrl(`/blog/${post.slug}`), url: absoluteUrl(`/blog/${post.slug}`),
publishedTime: String(post.publishedAt), publishedTime: post.publishedAt,
// A post with a hero image shares that; one without falls through to the // A post with a hero image shares that; one without falls through to the
// generated share card at app/opengraph-image.tsx. // generated share card at app/opengraph-image.tsx.
...(hero?.url ? { images: [{ url: hero.url }] } : {}), ...(hero?.url ? { images: [{ url: hero.url }] } : {}),
@@ -110,8 +115,8 @@ export default async function PostPage(
const post = await findPost(slug); const post = await findPost(slug);
if (!post) notFound(); if (!post) notFound();
const summary = summarise(post as Record<string, unknown>); const summary = summarise(post);
const hero = post.heroImage as HeroImage | null | undefined; const hero = heroOf(post);
const jsonLd = { const jsonLd = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
@@ -164,7 +169,7 @@ export default async function PostPage(
)} )}
<div className={styles.prose}> <div className={styles.prose}>
<RichText data={post.content as never} converters={calloutConverters} /> <RichText data={post.content} converters={calloutConverters} />
</div> </div>
</article> </article>
); );
+2 -2
View File
@@ -16,8 +16,8 @@ export const Callout: Block = {
type: 'select', type: 'select',
defaultValue: 'caveat', defaultValue: 'caveat',
options: [ options: [
{ label: 'Caveat — what this does not show', value: 'caveat' }, { label: 'Caveat: what this does not show', value: 'caveat' },
{ label: 'Note — useful aside', value: 'note' }, { label: 'Note: useful aside', value: 'note' },
], ],
}, },
{ name: 'body', type: 'textarea', required: true }, { name: 'body', type: 'textarea', required: true },
+14 -10
View File
@@ -1,12 +1,12 @@
# Publishing to the blog # Publishing to the blog
The blog is Payload CMS, running inside the Next.js app. There is no separate The blog is Payload CMS, running inside the Next.js app. There is no separate
service and no second deploy — writing a post is done in the browser and takes service and no second deploy. Writing a post is done in the browser and takes
effect on the live site within seconds. effect on the live site within seconds.
## Signing in ## Signing in
`https://www.schoolcompare.co.uk/admin` — one account, no registration. If you `https://www.schoolcompare.co.uk/admin`, one account, no registration. If you
need the account seeded on a fresh environment, run against the container: need the account seeded on a fresh environment, run against the container:
```bash ```bash
@@ -24,29 +24,29 @@ password there.
| Field | Notes | | Field | Notes |
|---|---| |---|---|
| **Title** | The `<h1>` and the browser tab. | | **Title** | The `<h1>` and the browser tab. |
| **Slug** | The URL segment, in the sidebar. **Never change it after publishing** — it is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. | | **Slug** | The URL segment, in the sidebar. **Never change it after publishing.** It is the canonical URL, and changing it breaks every existing link and discards the page's accumulated search signal. |
| **Published at** | The date shown on the post and in the feed. | | **Published at** | The date shown on the post and in the feed. |
| **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. | | **Excerpt** | Max 200 characters. Shown on the index *and* used as the meta description, so write it as a standalone sentence rather than a teaser. |
| **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. | | **Hero image** | Optional. Becomes the social share image; without one, the site's generated card is used. |
| **Content** | Rich text. `/` inserts a block. | | **Content** | Rich text. `/` inserts a block. |
**Save as draft** while you're working — drafts are not public. **Publish** when **Save as draft** while you're working; drafts are not public. **Publish** when
it's ready. it's ready.
### The callout block ### The callout block
One custom block, `Callout`, with two tones: One custom block, `Callout`, with two tones:
- **Caveat** — what a number does *not* show. This is the one that matters: it - **Caveat**: what a number does *not* show. This is the one that matters. It
is how a post states a limitation in context rather than burying it in a is how a post states a limitation in context rather than burying it in a
closing paragraph. closing paragraph.
- **Note** — a useful aside. - **Note**: a useful aside.
### Images ### Images
Every image requires alt text; the editor will not let you save without it. Every image requires alt text; the editor will not let you save without it.
Uploads go to a Docker volume on the host, which is backed up separately from Uploads go to a Docker volume on the host, which is backed up separately from
Postgres — an image is not reproducible from the pipeline the way school data Postgres. An image is not reproducible from the pipeline the way school data
is. is.
## How publishing reaches the live site ## How publishing reaches the live site
@@ -55,7 +55,7 @@ is.
so a new post appears immediately. so a new post appears immediately.
- `/blog/[slug]` is cached after its first request. Publishing or editing fires - `/blog/[slug]` is cached after its first request. Publishing or editing fires
a `revalidatePath` from the collection's `afterChange` hook, which drops that a `revalidatePath` from the collection's `afterChange` hook, which drops that
cached copy — so edits appear immediately too. cached copy, so edits appear immediately too.
If a change doesn't show, it is far more likely the post is still a draft than If a change doesn't show, it is far more likely the post is still a draft than
that the cache is stale. that the cache is stale.
@@ -65,7 +65,7 @@ that the cache is stale.
These rules are why the blog exists. A post that ignores them makes the site These rules are why the blog exists. A post that ignores them makes the site
read more machine-generated, not less. read more machine-generated, not less.
- **First person singular.** "I built", "I found" — never "we provide". - **First person singular.** "I built", "I found", never "we provide".
- **Concrete over general.** "When we were looking at schools in Wandsworth" - **Concrete over general.** "When we were looking at schools in Wandsworth"
beats any amount of stated warmth. beats any amount of stated warmth.
- **State limits before someone else finds them.** Every post that presents a - **State limits before someone else finds them.** Every post that presents a
@@ -73,10 +73,14 @@ read more machine-generated, not less.
human wrote it: generated content does not volunteer its own weaknesses. human wrote it: generated content does not volunteer its own weaknesses.
- **No mission statements, no "passionate about", no invented team.** There is - **No mission statements, no "passionate about", no invented team.** There is
one person here. one person here.
- **No em dashes.** They are one of the clearest tells of machine-written
prose, which is the whole problem this blog exists to fix. A full stop, a
colon, a semicolon or a pair of commas does the job and reads as though a
person chose it.
- **Short sentences.** - **Short sentences.**
- **Never publish a surname, an employer, or a child's name.** The site's author - **Never publish a surname, an employer, or a child's name.** The site's author
is "Tudor". See `/about`. is "Tudor". See `/about`.
- **Never invent a figure**, even illustratively. On a site whose whole - **Never invent a figure**, even illustratively. On a site whose whole
proposition is official data, a made-up number attached to a real school is proposition is official data, a made-up number attached to a real school is
the one thing it cannot do — and no illustrative intent survives being the one thing it cannot do, and no illustrative intent survives being
screenshotted. screenshotted.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,216 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db, payload, req }: MigrateUpArgs): Promise<void> {
/*
* Hand-added, and it must survive any regeneration of this file.
*
* `schemaName: 'payload'` tells Payload where to put its tables; it does not
* create the schema. Every statement below is qualified to "payload", so on
* a database that has never run this (staging and production both), the
* whole migration fails with `schema "payload" does not exist`. The schema
* only existed on the throwaway database used to generate this because it
* was created there by hand.
*/
await db.execute(sql`CREATE SCHEMA IF NOT EXISTS "payload";`)
await db.execute(sql`
CREATE TYPE "payload"."enum_posts_status" AS ENUM('draft', 'published');
CREATE TYPE "payload"."enum__posts_v_version_status" AS ENUM('draft', 'published');
CREATE TABLE "payload"."users_sessions" (
"_order" integer NOT NULL,
"_parent_id" integer NOT NULL,
"id" varchar PRIMARY KEY NOT NULL,
"created_at" timestamp(3) with time zone,
"expires_at" timestamp(3) with time zone NOT NULL
);
CREATE TABLE "payload"."users" (
"id" serial PRIMARY KEY NOT NULL,
"display_name" varchar DEFAULT 'Tudor' NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"email" varchar NOT NULL,
"reset_password_token" varchar,
"reset_password_expiration" timestamp(3) with time zone,
"salt" varchar,
"hash" varchar,
"login_attempts" numeric DEFAULT 0,
"lock_until" timestamp(3) with time zone
);
CREATE TABLE "payload"."posts" (
"id" serial PRIMARY KEY NOT NULL,
"title" varchar,
"slug" varchar,
"published_at" timestamp(3) with time zone,
"excerpt" varchar,
"hero_image_id" integer,
"content" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"_status" "payload"."enum_posts_status" DEFAULT 'draft'
);
CREATE TABLE "payload"."_posts_v" (
"id" serial PRIMARY KEY NOT NULL,
"parent_id" integer,
"version_title" varchar,
"version_slug" varchar,
"version_published_at" timestamp(3) with time zone,
"version_excerpt" varchar,
"version_hero_image_id" integer,
"version_content" jsonb,
"version_updated_at" timestamp(3) with time zone,
"version_created_at" timestamp(3) with time zone,
"version__status" "payload"."enum__posts_v_version_status" DEFAULT 'draft',
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"latest" boolean
);
CREATE TABLE "payload"."media" (
"id" serial PRIMARY KEY NOT NULL,
"alt" varchar NOT NULL,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"url" varchar,
"thumbnail_u_r_l" varchar,
"filename" varchar,
"mime_type" varchar,
"filesize" numeric,
"width" numeric,
"height" numeric,
"focal_x" numeric,
"focal_y" numeric,
"sizes_thumbnail_url" varchar,
"sizes_thumbnail_width" numeric,
"sizes_thumbnail_height" numeric,
"sizes_thumbnail_mime_type" varchar,
"sizes_thumbnail_filesize" numeric,
"sizes_thumbnail_filename" varchar,
"sizes_hero_url" varchar,
"sizes_hero_width" numeric,
"sizes_hero_height" numeric,
"sizes_hero_mime_type" varchar,
"sizes_hero_filesize" numeric,
"sizes_hero_filename" varchar
);
CREATE TABLE "payload"."payload_kv" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar NOT NULL,
"data" jsonb NOT NULL
);
CREATE TABLE "payload"."payload_locked_documents" (
"id" serial PRIMARY KEY NOT NULL,
"global_slug" varchar,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_locked_documents_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer,
"posts_id" integer,
"media_id" integer
);
CREATE TABLE "payload"."payload_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"key" varchar,
"value" jsonb,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
CREATE TABLE "payload"."payload_preferences_rels" (
"id" serial PRIMARY KEY NOT NULL,
"order" integer,
"parent_id" integer NOT NULL,
"path" varchar NOT NULL,
"users_id" integer
);
CREATE TABLE "payload"."payload_migrations" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar,
"batch" numeric,
"updated_at" timestamp(3) with time zone DEFAULT now() NOT NULL,
"created_at" timestamp(3) with time zone DEFAULT now() NOT NULL
);
ALTER TABLE "payload"."users_sessions" ADD CONSTRAINT "users_sessions_parent_id_fk" FOREIGN KEY ("_parent_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."posts" ADD CONSTRAINT "posts_hero_image_id_media_id_fk" FOREIGN KEY ("hero_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_parent_id_posts_id_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."posts"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."_posts_v" ADD CONSTRAINT "_posts_v_version_hero_image_id_media_id_fk" FOREIGN KEY ("version_hero_image_id") REFERENCES "payload"."media"("id") ON DELETE set null ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_locked_documents"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_posts_fk" FOREIGN KEY ("posts_id") REFERENCES "payload"."posts"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_locked_documents_rels" ADD CONSTRAINT "payload_locked_documents_rels_media_fk" FOREIGN KEY ("media_id") REFERENCES "payload"."media"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_parent_fk" FOREIGN KEY ("parent_id") REFERENCES "payload"."payload_preferences"("id") ON DELETE cascade ON UPDATE no action;
ALTER TABLE "payload"."payload_preferences_rels" ADD CONSTRAINT "payload_preferences_rels_users_fk" FOREIGN KEY ("users_id") REFERENCES "payload"."users"("id") ON DELETE cascade ON UPDATE no action;
CREATE INDEX "users_sessions_order_idx" ON "payload"."users_sessions" USING btree ("_order");
CREATE INDEX "users_sessions_parent_id_idx" ON "payload"."users_sessions" USING btree ("_parent_id");
CREATE INDEX "users_updated_at_idx" ON "payload"."users" USING btree ("updated_at");
CREATE INDEX "users_created_at_idx" ON "payload"."users" USING btree ("created_at");
CREATE UNIQUE INDEX "users_email_idx" ON "payload"."users" USING btree ("email");
CREATE UNIQUE INDEX "posts_slug_idx" ON "payload"."posts" USING btree ("slug");
CREATE INDEX "posts_hero_image_idx" ON "payload"."posts" USING btree ("hero_image_id");
CREATE INDEX "posts_updated_at_idx" ON "payload"."posts" USING btree ("updated_at");
CREATE INDEX "posts_created_at_idx" ON "payload"."posts" USING btree ("created_at");
CREATE INDEX "posts__status_idx" ON "payload"."posts" USING btree ("_status");
CREATE INDEX "_posts_v_parent_idx" ON "payload"."_posts_v" USING btree ("parent_id");
CREATE INDEX "_posts_v_version_version_slug_idx" ON "payload"."_posts_v" USING btree ("version_slug");
CREATE INDEX "_posts_v_version_version_hero_image_idx" ON "payload"."_posts_v" USING btree ("version_hero_image_id");
CREATE INDEX "_posts_v_version_version_updated_at_idx" ON "payload"."_posts_v" USING btree ("version_updated_at");
CREATE INDEX "_posts_v_version_version_created_at_idx" ON "payload"."_posts_v" USING btree ("version_created_at");
CREATE INDEX "_posts_v_version_version__status_idx" ON "payload"."_posts_v" USING btree ("version__status");
CREATE INDEX "_posts_v_created_at_idx" ON "payload"."_posts_v" USING btree ("created_at");
CREATE INDEX "_posts_v_updated_at_idx" ON "payload"."_posts_v" USING btree ("updated_at");
CREATE INDEX "_posts_v_latest_idx" ON "payload"."_posts_v" USING btree ("latest");
CREATE INDEX "media_updated_at_idx" ON "payload"."media" USING btree ("updated_at");
CREATE INDEX "media_created_at_idx" ON "payload"."media" USING btree ("created_at");
CREATE UNIQUE INDEX "media_filename_idx" ON "payload"."media" USING btree ("filename");
CREATE INDEX "media_sizes_thumbnail_sizes_thumbnail_filename_idx" ON "payload"."media" USING btree ("sizes_thumbnail_filename");
CREATE INDEX "media_sizes_hero_sizes_hero_filename_idx" ON "payload"."media" USING btree ("sizes_hero_filename");
CREATE UNIQUE INDEX "payload_kv_key_idx" ON "payload"."payload_kv" USING btree ("key");
CREATE INDEX "payload_locked_documents_global_slug_idx" ON "payload"."payload_locked_documents" USING btree ("global_slug");
CREATE INDEX "payload_locked_documents_updated_at_idx" ON "payload"."payload_locked_documents" USING btree ("updated_at");
CREATE INDEX "payload_locked_documents_created_at_idx" ON "payload"."payload_locked_documents" USING btree ("created_at");
CREATE INDEX "payload_locked_documents_rels_order_idx" ON "payload"."payload_locked_documents_rels" USING btree ("order");
CREATE INDEX "payload_locked_documents_rels_parent_idx" ON "payload"."payload_locked_documents_rels" USING btree ("parent_id");
CREATE INDEX "payload_locked_documents_rels_path_idx" ON "payload"."payload_locked_documents_rels" USING btree ("path");
CREATE INDEX "payload_locked_documents_rels_users_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("users_id");
CREATE INDEX "payload_locked_documents_rels_posts_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("posts_id");
CREATE INDEX "payload_locked_documents_rels_media_id_idx" ON "payload"."payload_locked_documents_rels" USING btree ("media_id");
CREATE INDEX "payload_preferences_key_idx" ON "payload"."payload_preferences" USING btree ("key");
CREATE INDEX "payload_preferences_updated_at_idx" ON "payload"."payload_preferences" USING btree ("updated_at");
CREATE INDEX "payload_preferences_created_at_idx" ON "payload"."payload_preferences" USING btree ("created_at");
CREATE INDEX "payload_preferences_rels_order_idx" ON "payload"."payload_preferences_rels" USING btree ("order");
CREATE INDEX "payload_preferences_rels_parent_idx" ON "payload"."payload_preferences_rels" USING btree ("parent_id");
CREATE INDEX "payload_preferences_rels_path_idx" ON "payload"."payload_preferences_rels" USING btree ("path");
CREATE INDEX "payload_preferences_rels_users_id_idx" ON "payload"."payload_preferences_rels" USING btree ("users_id");
CREATE INDEX "payload_migrations_updated_at_idx" ON "payload"."payload_migrations" USING btree ("updated_at");
CREATE INDEX "payload_migrations_created_at_idx" ON "payload"."payload_migrations" USING btree ("created_at");`)
}
export async function down({ db, payload, req }: MigrateDownArgs): Promise<void> {
await db.execute(sql`
DROP TABLE "payload"."users_sessions" CASCADE;
DROP TABLE "payload"."users" CASCADE;
DROP TABLE "payload"."posts" CASCADE;
DROP TABLE "payload"."_posts_v" CASCADE;
DROP TABLE "payload"."media" CASCADE;
DROP TABLE "payload"."payload_kv" CASCADE;
DROP TABLE "payload"."payload_locked_documents" CASCADE;
DROP TABLE "payload"."payload_locked_documents_rels" CASCADE;
DROP TABLE "payload"."payload_preferences" CASCADE;
DROP TABLE "payload"."payload_preferences_rels" CASCADE;
DROP TABLE "payload"."payload_migrations" CASCADE;
DROP TYPE "payload"."enum_posts_status";
DROP TYPE "payload"."enum__posts_v_version_status";`)
}
+9
View File
@@ -0,0 +1,9 @@
import * as migration_20260902_172826_initial from './20260902_172826_initial';
export const migrations = [
{
up: migration_20260902_172826_initial.up,
down: migration_20260902_172826_initial.down,
name: '20260902_172826_initial'
},
];
+443
View File
@@ -0,0 +1,443 @@
/* tslint:disable */
/* eslint-disable */
/**
* This file was automatically generated by Payload.
* DO NOT MODIFY IT BY HAND. Instead, modify your source Payload config,
* and re-run `payload generate:types` to regenerate this file.
*/
/**
* Supported timezones in IANA format.
*
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "supportedTimezones".
*/
export type SupportedTimezones =
| 'Pacific/Midway'
| 'Pacific/Niue'
| 'Pacific/Honolulu'
| 'Pacific/Rarotonga'
| 'America/Anchorage'
| 'Pacific/Gambier'
| 'America/Los_Angeles'
| 'America/Tijuana'
| 'America/Denver'
| 'America/Phoenix'
| 'America/Chicago'
| 'America/Guatemala'
| 'America/New_York'
| 'America/Bogota'
| 'America/Caracas'
| 'America/Santiago'
| 'America/Buenos_Aires'
| 'America/Sao_Paulo'
| 'Atlantic/South_Georgia'
| 'Atlantic/Azores'
| 'Atlantic/Cape_Verde'
| 'Europe/London'
| 'Europe/Berlin'
| 'Africa/Lagos'
| 'Europe/Athens'
| 'Africa/Cairo'
| 'Europe/Moscow'
| 'Asia/Riyadh'
| 'Asia/Dubai'
| 'Asia/Baku'
| 'Asia/Karachi'
| 'Asia/Tashkent'
| 'Asia/Calcutta'
| 'Asia/Dhaka'
| 'Asia/Almaty'
| 'Asia/Jakarta'
| 'Asia/Bangkok'
| 'Asia/Shanghai'
| 'Asia/Singapore'
| 'Asia/Tokyo'
| 'Asia/Seoul'
| 'Australia/Brisbane'
| 'Australia/Sydney'
| 'Pacific/Guam'
| 'Pacific/Noumea'
| 'Pacific/Auckland'
| 'Pacific/Fiji';
export interface Config {
auth: {
users: UserAuthOperations;
};
blocks: {};
collections: {
users: User;
posts: Post;
media: Media;
'payload-kv': PayloadKv;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
};
collectionsJoins: {};
collectionsSelect: {
users: UsersSelect<false> | UsersSelect<true>;
posts: PostsSelect<false> | PostsSelect<true>;
media: MediaSelect<false> | MediaSelect<true>;
'payload-kv': PayloadKvSelect<false> | PayloadKvSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
};
db: {
defaultIDType: number;
};
fallbackLocale: null;
globals: {};
globalsSelect: {};
locale: null;
widgets: {
collections: CollectionsWidget;
};
user: User;
jobs: {
tasks: unknown;
workflows: unknown;
};
}
export interface UserAuthOperations {
forgotPassword: {
email: string;
password: string;
};
login: {
email: string;
password: string;
};
registerFirstUser: {
email: string;
password: string;
};
unlock: {
email: string;
password: string;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users".
*/
export interface User {
id: number;
displayName: string;
updatedAt: string;
createdAt: string;
email: string;
resetPasswordToken?: string | null;
resetPasswordExpiration?: string | null;
salt?: string | null;
hash?: string | null;
loginAttempts?: number | null;
lockUntil?: string | null;
sessions?:
| {
id: string;
createdAt?: string | null;
expiresAt: string;
}[]
| null;
password?: string | null;
collection: 'users';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts".
*/
export interface Post {
id: number;
title: string;
/**
* The URL segment. Never change it after publishing.
*/
slug: string;
publishedAt: string;
/**
* Shown on the index and used as the meta description.
*/
excerpt: string;
heroImage?: (number | null) | Media;
content: {
root: {
type: string;
children: {
type: any;
version: number;
[k: string]: unknown;
}[];
direction: ('ltr' | 'rtl') | null;
format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | '';
indent: number;
version: number;
};
[k: string]: unknown;
};
updatedAt: string;
createdAt: string;
_status?: ('draft' | 'published') | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
*/
export interface Media {
id: number;
/**
* Describe the image for screen readers.
*/
alt: string;
updatedAt: string;
createdAt: string;
url?: string | null;
thumbnailURL?: string | null;
filename?: string | null;
mimeType?: string | null;
filesize?: number | null;
width?: number | null;
height?: number | null;
focalX?: number | null;
focalY?: number | null;
sizes?: {
thumbnail?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
hero?: {
url?: string | null;
width?: number | null;
height?: number | null;
mimeType?: string | null;
filesize?: number | null;
filename?: string | null;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv".
*/
export interface PayloadKv {
id: number;
key: string;
data:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents".
*/
export interface PayloadLockedDocument {
id: number;
document?:
| ({
relationTo: 'users';
value: number | User;
} | null)
| ({
relationTo: 'posts';
value: number | Post;
} | null)
| ({
relationTo: 'media';
value: number | Media;
} | null);
globalSlug?: string | null;
user: {
relationTo: 'users';
value: number | User;
};
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences".
*/
export interface PayloadPreference {
id: number;
user: {
relationTo: 'users';
value: number | User;
};
key?: string | null;
value?:
| {
[k: string]: unknown;
}
| unknown[]
| string
| number
| boolean
| null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations".
*/
export interface PayloadMigration {
id: number;
name?: string | null;
batch?: number | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "users_select".
*/
export interface UsersSelect<T extends boolean = true> {
displayName?: T;
updatedAt?: T;
createdAt?: T;
email?: T;
resetPasswordToken?: T;
resetPasswordExpiration?: T;
salt?: T;
hash?: T;
loginAttempts?: T;
lockUntil?: T;
sessions?:
| T
| {
id?: T;
createdAt?: T;
expiresAt?: T;
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "posts_select".
*/
export interface PostsSelect<T extends boolean = true> {
title?: T;
slug?: T;
publishedAt?: T;
excerpt?: T;
heroImage?: T;
content?: T;
updatedAt?: T;
createdAt?: T;
_status?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media_select".
*/
export interface MediaSelect<T extends boolean = true> {
alt?: T;
updatedAt?: T;
createdAt?: T;
url?: T;
thumbnailURL?: T;
filename?: T;
mimeType?: T;
filesize?: T;
width?: T;
height?: T;
focalX?: T;
focalY?: T;
sizes?:
| T
| {
thumbnail?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
hero?:
| T
| {
url?: T;
width?: T;
height?: T;
mimeType?: T;
filesize?: T;
filename?: T;
};
};
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-kv_select".
*/
export interface PayloadKvSelect<T extends boolean = true> {
key?: T;
data?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
*/
export interface PayloadLockedDocumentsSelect<T extends boolean = true> {
document?: T;
globalSlug?: T;
user?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-preferences_select".
*/
export interface PayloadPreferencesSelect<T extends boolean = true> {
user?: T;
key?: T;
value?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-migrations_select".
*/
export interface PayloadMigrationsSelect<T extends boolean = true> {
name?: T;
batch?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "collections_widget".
*/
export interface CollectionsWidget {
data?: {
[k: string]: unknown;
};
width: 'full';
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "auth".
*/
export interface Auth {
[k: string]: unknown;
}
declare module 'payload' {
export interface GeneratedTypes extends Config {}
}
+9 -1
View File
@@ -8,6 +8,7 @@ import { Users } from '@/collections/Users';
import { Posts } from '@/collections/Posts'; import { Posts } from '@/collections/Posts';
import { Media } from '@/collections/Media'; import { Media } from '@/collections/Media';
import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes'; import { PAYLOAD_API_ROUTE, PAYLOAD_ADMIN_ROUTE } from '@/lib/payloadRoutes';
import { migrations } from '@/migrations';
const filename = fileURLToPath(import.meta.url); const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename); const dirname = path.dirname(filename);
@@ -25,8 +26,15 @@ export default buildConfig({
pool: { connectionString: process.env.DATABASE_URL }, pool: { connectionString: process.env.DATABASE_URL },
// Its own schema, so no pipeline operation on `public` can reach blog // Its own schema, so no pipeline operation on `public` can reach blog
// content. scripts/migrate_csv_to_db.py --drop lives in that blast radius, // content. scripts/migrate_csv_to_db.py --drop lives in that blast radius,
// as does Airflow's metadata. // as does Airflow's metadata. The schema itself is created by the initial
// migration: schemaName says where tables go, it does not create anything.
//
// prodMigrations runs pending migrations during server init. Without it a
// production container connects to an empty schema and fails its first
// query with 42P01 — the adapter cannot self-create tables, because
// db-postgres/connect.js gates push on NODE_ENV !== 'production'.
schemaName: 'payload', schemaName: 'payload',
prodMigrations: migrations,
}), }),
sharp, sharp,
}); });