@forumone/create-throughline 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +125 -0
- package/dist/generator.d.ts.map +1 -1
- package/dist/generator.js +5 -0
- package/dist/generator.js.map +1 -1
- package/dist/post-install.d.ts.map +1 -1
- package/dist/post-install.js +10 -3
- package/dist/post-install.js.map +1 -1
- package/dist/templates/base/.env.example +32 -18
- package/dist/templates/base/CLAUDE.md +112 -0
- package/dist/templates/base/README.md +92 -7
- package/dist/templates/base/apps/web/e2e/site.ts +36 -0
- package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
- package/dist/templates/base/apps/web/package.json.template +23 -7
- package/dist/templates/base/apps/web/playwright.config.ts +63 -0
- package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
- package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
- package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
- package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +36 -0
- package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +41 -4
- package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
- package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
- package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
- package/dist/templates/base/apps/web/src/payload.config.ts.template +136 -19
- package/dist/templates/base/apps/web/vitest.config.ts +57 -0
- package/dist/templates/base/claude/hooks/gate.sh +45 -0
- package/dist/templates/base/claude/settings.json +16 -0
- package/dist/templates/base/github/scripts/check-generated.sh +44 -0
- package/dist/templates/base/github/workflows/ci.yml +143 -0
- package/dist/templates/base/gitignore +9 -0
- package/dist/templates/base/package.json.template +7 -1
- package/dist/templates/base/scripts/check-audit.ts +117 -0
- package/dist/templates/base/scripts/check-single-instance.ts +113 -0
- package/dist/templates/base/scripts/check-tested-packages.ts +163 -0
- package/dist/templates/base/turbo.json +16 -1
- package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
- package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
- package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +1 -0
- package/dist/versions.json +11 -11
- package/package.json +5 -4
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
"start": "next start",
|
|
10
10
|
"clean": "rm -rf .next .turbo",
|
|
11
11
|
"typecheck": "tsc --noEmit",
|
|
12
|
-
"payload": "payload"
|
|
12
|
+
"payload": "throughline-payload",
|
|
13
|
+
"payload:reap": "throughline-payload --reap",
|
|
14
|
+
"generate:types": "throughline-payload generate:types",
|
|
15
|
+
"generate:importmap": "throughline-payload generate:importmap",
|
|
16
|
+
"migrate": "PAYLOAD_MIGRATING=1 throughline-payload migrate",
|
|
17
|
+
"migrate:create": "throughline-payload migrate:create",
|
|
18
|
+
"migrate:status": "throughline-payload migrate:status",
|
|
19
|
+
"migrate:snapshot": "tsx scripts/write-migration-snapshot.ts",
|
|
20
|
+
"check:migrations": "tsx scripts/check-migrations.ts",
|
|
21
|
+
"test": "vitest run",
|
|
22
|
+
"test:watch": "vitest",
|
|
23
|
+
"test:smoke": "playwright test",
|
|
24
|
+
"test:smoke:install": "playwright install --with-deps chromium"
|
|
13
25
|
},
|
|
14
26
|
"dependencies": {
|
|
15
27
|
"@forumone/throughline-approvals": "^{{version_approvals}}",
|
|
@@ -23,21 +35,25 @@
|
|
|
23
35
|
"@forumone/throughline-workflows": "^{{version_workflows}}",
|
|
24
36
|
{{#if useReferenceDs}}"@forumone/throughline-design-contract": "^{{version_design_contract}}",
|
|
25
37
|
"{{designSystemName}}": "workspace:*",
|
|
26
|
-
{{/if}}"@payloadcms/db-postgres": "^3.
|
|
27
|
-
"@payloadcms/next": "^3.
|
|
28
|
-
"@payloadcms/plugin-form-builder": "3.
|
|
29
|
-
"@payloadcms/
|
|
38
|
+
{{/if}}"@payloadcms/db-postgres": "^3.89.0",
|
|
39
|
+
"@payloadcms/next": "^3.89.0",
|
|
40
|
+
"@payloadcms/plugin-form-builder": "^3.89.0",
|
|
41
|
+
"@payloadcms/plugin-mcp": "^3.89.0",
|
|
42
|
+
"@payloadcms/richtext-lexical": "^3.89.0",
|
|
30
43
|
"graphql": "^16.8.1",
|
|
31
44
|
"inngest": "^4.2.0",
|
|
32
45
|
"next": "^16.2.4",
|
|
33
|
-
"payload": "^3.
|
|
46
|
+
"payload": "^3.89.0",
|
|
34
47
|
"react": "^19.2.0",
|
|
35
48
|
"react-dom": "^19.2.0"
|
|
36
49
|
},
|
|
37
50
|
"devDependencies": {
|
|
51
|
+
"@playwright/test": "^1.63.0",
|
|
38
52
|
"@types/node": "^20.17.0",
|
|
39
53
|
"@types/react": "^19.2.0",
|
|
40
54
|
"@types/react-dom": "^19.2.0",
|
|
41
|
-
"
|
|
55
|
+
"tsx": "^4.21.0",
|
|
56
|
+
"typescript": "^5.6.0",
|
|
57
|
+
"vitest": "^4.1.10"
|
|
42
58
|
}
|
|
43
59
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
import { site } from './e2e/site'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
The smoke pack: a real server answering real requests.
|
|
6
|
+
|
|
7
|
+
The vitest suite (the `*.test.ts` files under `src`) checks values — access rules, field
|
|
8
|
+
validations — and connects to nothing. What it cannot reach is anything true
|
|
9
|
+
only of a *response*: that the admin serves a login screen, that an unknown
|
|
10
|
+
path is a 404 rather than a 500, that the REST API refuses an anonymous read of
|
|
11
|
+
`users` in practice and not only on paper. That is this.
|
|
12
|
+
|
|
13
|
+
**Deliberately not a functional suite.** It runs against an empty, migrated
|
|
14
|
+
database — CI's `verify` job creates one — so nothing here needs a document to
|
|
15
|
+
exist. Anything that does belongs in a seeded environment.
|
|
16
|
+
|
|
17
|
+
pnpm --dir apps/web build && pnpm --dir apps/web test:smoke # local build
|
|
18
|
+
E2E_BASE_URL=https://example.org pnpm --dir apps/web test:smoke # deployed
|
|
19
|
+
|
|
20
|
+
With `E2E_BASE_URL` set the pack runs against that URL and starts no server.
|
|
21
|
+
Every check is a GET, which is what makes pointing it at production defensible;
|
|
22
|
+
adding one that writes would not be. Which routes it checks is in `e2e/site.ts`.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/*
|
|
26
|
+
`next start`, not `next dev`: headers, caching and rendering are properties of
|
|
27
|
+
the built output, and a pack against `dev` would assert something nobody
|
|
28
|
+
deploys. So this needs `.next/` — in CI the build step has made it; locally,
|
|
29
|
+
run `pnpm build` first.
|
|
30
|
+
|
|
31
|
+
Hoisted, and spread in only when there is no URL to point at: with
|
|
32
|
+
`exactOptionalPropertyTypes` an explicit `webServer: undefined` is a type
|
|
33
|
+
error, and omitting the key is the only way to say "start nothing".
|
|
34
|
+
*/
|
|
35
|
+
const webServer = {
|
|
36
|
+
command: `pnpm exec next start --port ${String(site.port)}`,
|
|
37
|
+
url: `http://127.0.0.1:${String(site.port)}${site.home}`,
|
|
38
|
+
reuseExistingServer: !process.env.CI,
|
|
39
|
+
timeout: 120_000,
|
|
40
|
+
stdout: 'pipe' as const,
|
|
41
|
+
stderr: 'pipe' as const,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
testDir: './e2e',
|
|
46
|
+
outputDir: './e2e/.results',
|
|
47
|
+
|
|
48
|
+
// A cold `next start` can lose the first request to a route while it warms.
|
|
49
|
+
// Nothing here is a visual comparison, so one retry cannot hide a real failure.
|
|
50
|
+
retries: process.env.CI ? 1 : 0,
|
|
51
|
+
forbidOnly: Boolean(process.env.CI),
|
|
52
|
+
reporter: process.env.CI ? [['github'], ['list']] : [['list']],
|
|
53
|
+
workers: 1,
|
|
54
|
+
|
|
55
|
+
use: {
|
|
56
|
+
baseURL: site.baseURL ?? `http://127.0.0.1:${String(site.port)}`,
|
|
57
|
+
trace: 'retain-on-failure',
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
|
61
|
+
|
|
62
|
+
...(site.baseURL ? {} : { webServer }),
|
|
63
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Three checks on `src/migrations`, each about a failure nobody sees until the
|
|
7
|
+
moment it has to work.
|
|
8
|
+
|
|
9
|
+
pnpm check:migrations
|
|
10
|
+
|
|
11
|
+
With `push: false` a migration is the only way this schema ever changes, so the
|
|
12
|
+
migration chain has to stay usable.
|
|
13
|
+
|
|
14
|
+
## 1. A migration with no schema snapshot
|
|
15
|
+
|
|
16
|
+
`migrate:create` does not diff the config against the database. It diffs it
|
|
17
|
+
against the `.json` snapshot beside the **most recent** migration, and writes one
|
|
18
|
+
only when it generated the SQL itself. So a migration written by hand leaves the
|
|
19
|
+
generator a step behind, and every run after it re-proposes a change that
|
|
20
|
+
already shipped — often as drizzle's interactive rename prompt, which cannot be
|
|
21
|
+
answered non-interactively, so the command exits having written nothing.
|
|
22
|
+
|
|
23
|
+
Every migration is checked, not only the newest: a gap in the middle is inert
|
|
24
|
+
today and was a blocked generator on the day it merged, which is the day this
|
|
25
|
+
needs to fail. Write one with `pnpm migrate:snapshot`.
|
|
26
|
+
|
|
27
|
+
## 2. An orphaned snapshot
|
|
28
|
+
|
|
29
|
+
A `.json` with no `.ts` beside it. Delete a migration and leave its snapshot,
|
|
30
|
+
and that snapshot sorts last and becomes what the generator diffs against — a
|
|
31
|
+
schema no database will ever be in.
|
|
32
|
+
|
|
33
|
+
## 3. A drop that a CASCADE has already carried out
|
|
34
|
+
|
|
35
|
+
Payload's generator can emit, in this order:
|
|
36
|
+
|
|
37
|
+
DROP TABLE "posts" CASCADE;
|
|
38
|
+
ALTER TABLE "search_rels" DROP CONSTRAINT "search_rels_posts_fk";
|
|
39
|
+
|
|
40
|
+
The CASCADE already removed that constraint, so the second statement errors,
|
|
41
|
+
and because a migration runs in a transaction it takes the whole migration — or
|
|
42
|
+
the whole rollback — with it. What is flagged is a bare `DROP CONSTRAINT` or
|
|
43
|
+
`DROP INDEX` that follows a cascading `DROP` in the same function. A bare drop
|
|
44
|
+
with no CASCADE above it is dropping something that is still there, and
|
|
45
|
+
demanding `IF EXISTS` of it would be the kind of blanket rule people learn to
|
|
46
|
+
suppress.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
50
|
+
const MIGRATIONS = path.resolve(here, '../src/migrations')
|
|
51
|
+
|
|
52
|
+
const BARE_DROP = /\bDROP\s+(CONSTRAINT|INDEX)\s+"/gi
|
|
53
|
+
|
|
54
|
+
// A `DROP` that cascaded, kept inside one statement by `[^;]*`. Not the word
|
|
55
|
+
// on its own, which also matches the `ON DELETE cascade` of every foreign key.
|
|
56
|
+
const CASCADE = /\bDROP\b[^;]*\bCASCADE\b/i
|
|
57
|
+
|
|
58
|
+
const stem = (name: string) => name.replace(/\.(json|ts)$/, '')
|
|
59
|
+
|
|
60
|
+
const entries = await readdir(MIGRATIONS).catch((error: NodeJS.ErrnoException) => {
|
|
61
|
+
if (error.code === 'ENOENT') return [] as string[]
|
|
62
|
+
throw error
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const files = entries.filter((name) => name.endsWith('.ts') && name !== 'index.ts').sort()
|
|
66
|
+
|
|
67
|
+
if (files.length === 0) {
|
|
68
|
+
console.log('No migrations yet. Create the first with:\n\n' + ' pnpm migrate:create initial\n')
|
|
69
|
+
process.exit(0)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Basenames on both sides, because that is what `createMigration` sorts: it
|
|
73
|
+
// reads the directory, keeps the `.json`, sorts, and takes the last.
|
|
74
|
+
const snapshots = new Set(entries.filter((name) => name.endsWith('.json')).map(stem))
|
|
75
|
+
|
|
76
|
+
const missing = files.filter((file) => !snapshots.has(stem(file)))
|
|
77
|
+
|
|
78
|
+
if (missing.length > 0) {
|
|
79
|
+
console.error(
|
|
80
|
+
`${missing.length} migration(s) with no schema snapshot:\n\n` +
|
|
81
|
+
missing.map((file) => ` ${file}\n ${stem(file)}.json <- missing`).join('\n\n') +
|
|
82
|
+
'\n\nmigrate:create diffs the config against the snapshot beside the most\n' +
|
|
83
|
+
'recent migration. Without one it diffs against a schema that is a step\n' +
|
|
84
|
+
'behind, and re-proposes a change that has already shipped. Write it with:\n\n' +
|
|
85
|
+
' pnpm migrate:snapshot\n\n' +
|
|
86
|
+
'Run it only once the migration is complete: the snapshot claims the\n' +
|
|
87
|
+
'database matches the config.\n',
|
|
88
|
+
)
|
|
89
|
+
process.exit(1)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const migrationNames = new Set(files.map(stem))
|
|
93
|
+
const orphans = [...snapshots].filter((name) => !migrationNames.has(name)).sort()
|
|
94
|
+
|
|
95
|
+
if (orphans.length > 0) {
|
|
96
|
+
console.error(
|
|
97
|
+
`${orphans.length} schema snapshot(s) with no migration beside them:\n\n` +
|
|
98
|
+
orphans.map((name) => ` ${name}.json`).join('\n') +
|
|
99
|
+
'\n\nA snapshot sorting last is what migrate:create diffs against, so one\n' +
|
|
100
|
+
'left behind by a deleted migration describes a schema no database will\n' +
|
|
101
|
+
'reach. Delete it, or restore the migration it belongs to.\n',
|
|
102
|
+
)
|
|
103
|
+
process.exit(1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const problems: Array<{ file: string; statement: string }> = []
|
|
107
|
+
|
|
108
|
+
for (const file of files) {
|
|
109
|
+
const source = await readFile(path.join(MIGRATIONS, file), 'utf8')
|
|
110
|
+
|
|
111
|
+
for (const name of ['up', 'down'] as const) {
|
|
112
|
+
const start = source.indexOf(`export async function ${name}`)
|
|
113
|
+
if (start === -1) continue
|
|
114
|
+
const other = source.indexOf('export async function', start + 1)
|
|
115
|
+
const body = source.slice(start, other === -1 ? undefined : other)
|
|
116
|
+
|
|
117
|
+
for (const match of body.matchAll(BARE_DROP)) {
|
|
118
|
+
const at = match.index ?? 0
|
|
119
|
+
if (!CASCADE.test(body.slice(0, at))) continue
|
|
120
|
+
|
|
121
|
+
const absolute = start + at
|
|
122
|
+
const lineStart = source.lastIndexOf('\n', absolute) + 1
|
|
123
|
+
const lineEnd = source.indexOf('\n', absolute)
|
|
124
|
+
problems.push({
|
|
125
|
+
file,
|
|
126
|
+
statement: source.slice(lineStart, lineEnd === -1 ? undefined : lineEnd).trim(),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (problems.length > 0) {
|
|
133
|
+
console.error(
|
|
134
|
+
`${problems.length} drop(s) after a CASCADE without IF EXISTS:\n\n` +
|
|
135
|
+
problems.map((p) => ` ${p.file}\n ${p.statement}`).join('\n\n') +
|
|
136
|
+
'\n\nA preceding DROP ... CASCADE has already removed these, so the\n' +
|
|
137
|
+
'migration will abort. Add IF EXISTS:\n\n' +
|
|
138
|
+
' DROP CONSTRAINT IF EXISTS "..."\n' +
|
|
139
|
+
' DROP INDEX IF EXISTS "..."\n',
|
|
140
|
+
)
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
console.log(
|
|
145
|
+
`Checked ${files.length} migration(s): every drop is guarded, every migration has\n` +
|
|
146
|
+
`its schema snapshot, and the generator will diff against ${stem(files.at(-1)!)}.`,
|
|
147
|
+
)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { resolveDatabaseConnection } from '../../src/lib/database'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Which database a script is about to write to, and an explicit confirmation of
|
|
5
|
+
it. For seed, import and repair scripts:
|
|
6
|
+
|
|
7
|
+
const where = requireTarget(process.argv, overwriting, 'Replaces every page')
|
|
8
|
+
|
|
9
|
+
A script reads its connection string from the environment like everything else,
|
|
10
|
+
so a shell that happens to have production's connection string loaded points it
|
|
11
|
+
at production and nothing asks. This prints the target before anything is
|
|
12
|
+
written, and demands `--confirm <database>@<host>` when the run would overwrite.
|
|
13
|
+
|
|
14
|
+
It is not authentication — anyone who can run the script can type the token. It
|
|
15
|
+
is the difference between a mistake and a decision, and the print alone is worth
|
|
16
|
+
it: a run aimed at the wrong host is visible before it writes rather than after.
|
|
17
|
+
|
|
18
|
+
Sized to the risk: creating what is missing is recoverable by deleting it;
|
|
19
|
+
replacing what an editor has worked on is not.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface Target {
|
|
23
|
+
host: string
|
|
24
|
+
database: string
|
|
25
|
+
/** The environment variable the connection string came from, or `(none)`. */
|
|
26
|
+
source: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The database the config will actually connect to — through the same resolver
|
|
31
|
+
* `payload.config.ts` uses, not by reading `DATABASE_URI` directly. A guard that
|
|
32
|
+
* reports a different database than the code connects to is not a weaker guard;
|
|
33
|
+
* it is a false one.
|
|
34
|
+
*/
|
|
35
|
+
export function target(): Target {
|
|
36
|
+
let connection = ''
|
|
37
|
+
let source = '(none)'
|
|
38
|
+
try {
|
|
39
|
+
const resolved = resolveDatabaseConnection()
|
|
40
|
+
connection = resolved.value
|
|
41
|
+
source = resolved.name
|
|
42
|
+
} catch {
|
|
43
|
+
// Nothing set at all: a target of "none", which every destructive run refuses.
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const parsed = new URL(connection)
|
|
48
|
+
return { host: parsed.hostname, database: parsed.pathname.replace(/^\//, ''), source }
|
|
49
|
+
} catch {
|
|
50
|
+
return { host: '(unparseable)', database: '', source }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/*
|
|
55
|
+
The token that has to be typed back. Not the database name on its own: Neon
|
|
56
|
+
names every branch's database `neondb`, so `--confirm neondb` would match
|
|
57
|
+
production and a preview alike. The host distinguishes them, so the host is in
|
|
58
|
+
the token — long enough that it cannot be typed from memory while thinking about
|
|
59
|
+
a different environment.
|
|
60
|
+
*/
|
|
61
|
+
export const confirmToken = (where: Target): string => `${where.database}@${where.host}`
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Print the target, and exit unless a destructive run has named it back.
|
|
65
|
+
*
|
|
66
|
+
* @param argv The script's arguments, for `--confirm <database>@<host>`.
|
|
67
|
+
* @param destructive Whether this run would overwrite existing documents.
|
|
68
|
+
* @param consequence One line naming what would be overwritten, shown in the refusal.
|
|
69
|
+
*/
|
|
70
|
+
export function requireTarget(argv: string[], destructive: boolean, consequence: string): Target {
|
|
71
|
+
const where = target()
|
|
72
|
+
console.log(
|
|
73
|
+
`\n target ${where.database || '(none)'} on ${where.host}\n from ${where.source}`,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if (!destructive) return where
|
|
77
|
+
|
|
78
|
+
const index = argv.indexOf('--confirm')
|
|
79
|
+
const confirmed = index === -1 ? undefined : argv[index + 1]
|
|
80
|
+
const expected = confirmToken(where)
|
|
81
|
+
|
|
82
|
+
if (where.database === '' || confirmed !== expected) {
|
|
83
|
+
console.error(
|
|
84
|
+
`\n Refusing to write.\n\n` +
|
|
85
|
+
` ${consequence}\n` +
|
|
86
|
+
` in the database "${where.database}" on ${where.host},\n` +
|
|
87
|
+
` read from ${where.source}.\n\n` +
|
|
88
|
+
` Re-run with --confirm ${where.database ? expected : '<database>@<host>'}\n` +
|
|
89
|
+
` if that is what you meant.\n`,
|
|
90
|
+
)
|
|
91
|
+
process.exit(2)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return where
|
|
95
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { getPayload } from 'payload'
|
|
6
|
+
import config from '@payload-config'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Writes the `.json` schema snapshot beside a hand-written migration.
|
|
10
|
+
|
|
11
|
+
pnpm migrate:snapshot # the newest migration
|
|
12
|
+
pnpm migrate:snapshot 20260812_154500_add_jobs
|
|
13
|
+
pnpm migrate:snapshot --force # replace an existing snapshot
|
|
14
|
+
|
|
15
|
+
`migrate:create` diffs the config against the snapshot beside the **most
|
|
16
|
+
recent** migration, and writes a fresh one only when it generates the SQL
|
|
17
|
+
itself. A migration written by hand therefore leaves the generator one step
|
|
18
|
+
behind, and every run after it re-proposes a change that already shipped.
|
|
19
|
+
|
|
20
|
+
This closes the gap the way `createMigration` does, minus the diff: it asks the
|
|
21
|
+
adapter for the schema the config describes and serialises it. The adapter
|
|
22
|
+
builds that schema from the config alone, so this needs **no database**.
|
|
23
|
+
|
|
24
|
+
## When it would be a lie
|
|
25
|
+
|
|
26
|
+
The snapshot says: *the database, once this migration has run, matches the
|
|
27
|
+
config.* Run it only after the hand-written migration is complete. A snapshot
|
|
28
|
+
claiming a config change the migration only half implements makes the next
|
|
29
|
+
generated migration quietly omit the missing half.
|
|
30
|
+
|
|
31
|
+
Overwriting needs `--force`, because the common mistake is running this instead
|
|
32
|
+
of writing the migration: the snapshot is rewritten to match a config that moved
|
|
33
|
+
on, and the change becomes invisible to the generator — the schema drifts with
|
|
34
|
+
no migration anywhere that would apply it.
|
|
35
|
+
|
|
36
|
+
The config is loaded, so the environment it validates must be present. Values
|
|
37
|
+
that only need to parse — secrets, keys — can be placeholders on this run.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
41
|
+
const MIGRATIONS = path.resolve(here, '../src/migrations')
|
|
42
|
+
|
|
43
|
+
// `pnpm run x -- --flag` forwards the `--` itself, which `parseArgs` would read
|
|
44
|
+
// as "positionals from here".
|
|
45
|
+
const argv = process.argv.slice(2).filter((arg) => arg !== '--')
|
|
46
|
+
|
|
47
|
+
const { positionals, values } = parseArgs({
|
|
48
|
+
args: argv,
|
|
49
|
+
allowPositionals: true,
|
|
50
|
+
options: { force: { type: 'boolean', default: false } },
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const migrations = (await readdir(MIGRATIONS).catch(() => [] as string[]))
|
|
54
|
+
.filter((name) => name.endsWith('.ts') && name !== 'index.ts')
|
|
55
|
+
.map((name) => name.slice(0, -'.ts'.length))
|
|
56
|
+
.sort()
|
|
57
|
+
|
|
58
|
+
if (migrations.length === 0) {
|
|
59
|
+
console.error(`No migrations in ${MIGRATIONS}.`)
|
|
60
|
+
process.exit(1)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const target = positionals[0]?.replace(/\.(json|ts)$/, '') ?? migrations.at(-1)!
|
|
64
|
+
|
|
65
|
+
if (!migrations.includes(target)) {
|
|
66
|
+
console.error(
|
|
67
|
+
`No migration named ${target}.\n\nMigrations in src/migrations:\n` +
|
|
68
|
+
migrations.map((name) => ` ${name}`).join('\n') +
|
|
69
|
+
'\n',
|
|
70
|
+
)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const destination = path.join(MIGRATIONS, `${target}.json`)
|
|
75
|
+
const existing = await readFile(destination, 'utf8').then(
|
|
76
|
+
() => true,
|
|
77
|
+
() => false,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if (existing && !values.force) {
|
|
81
|
+
console.error(
|
|
82
|
+
`${target}.json already exists, and describes the schema as of that migration.\n` +
|
|
83
|
+
'Rewriting it to match a config that has since changed hides the change from\n' +
|
|
84
|
+
'`migrate:create`. If replacing it is genuinely what you want, pass --force.\n',
|
|
85
|
+
)
|
|
86
|
+
process.exit(1)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (target !== migrations.at(-1)) {
|
|
90
|
+
console.warn(
|
|
91
|
+
`Warning: ${target} is not the newest migration. Only the newest snapshot is\n` +
|
|
92
|
+
'read by migrate:create, so this describes a state nothing will diff against.\n',
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// No database, and nothing that runs on startup (seeds, syncs, cron
|
|
97
|
+
// registration) from a script whose whole job is to serialise a schema.
|
|
98
|
+
const payload = await getPayload({ config, disableDBConnect: true, disableOnInit: true })
|
|
99
|
+
|
|
100
|
+
const db = payload.db as typeof payload.db & {
|
|
101
|
+
requireDrizzleKit: () => { generateDrizzleJson: (schema: unknown) => Promise<unknown> }
|
|
102
|
+
schema: unknown
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { generateDrizzleJson } = db.requireDrizzleKit()
|
|
106
|
+
const snapshot = await generateDrizzleJson(db.schema)
|
|
107
|
+
|
|
108
|
+
// Byte for byte what `createMigration` writes, with no trailing newline, so a
|
|
109
|
+
// hand-written migration's snapshot is indistinguishable from a generated one.
|
|
110
|
+
await writeFile(destination, JSON.stringify(snapshot, null, 2))
|
|
111
|
+
|
|
112
|
+
console.log(
|
|
113
|
+
`Wrote src/migrations/${target}.json\n\n` +
|
|
114
|
+
'Commit it beside the migration. Confirm the generator is unblocked with:\n\n' +
|
|
115
|
+
' pnpm migrate:create check --skip-empty\n\n' +
|
|
116
|
+
'which exits without writing anything when there is no diff.\n',
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
process.exit(0)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describeAnonymousAccess } from '@forumone/throughline-core/testing'
|
|
2
|
+
import config from '../payload.config'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
What a reader with no account can read, as values — no database.
|
|
6
|
+
|
|
7
|
+
Every query the public site makes runs with nobody signed in, so a collection's
|
|
8
|
+
own `read` rule decides whether a page can see its content. Close one by
|
|
9
|
+
mistake and nothing errors: the page renders empty. Open the wrong one and the
|
|
10
|
+
audit log, the approval queue or the MCP keys are on the internet.
|
|
11
|
+
|
|
12
|
+
Every collection in the config — yours and the ones the plugins bring — has to
|
|
13
|
+
be in exactly one bucket below. **Adding a collection fails this test until you
|
|
14
|
+
put it in one.** Say where the site reads it, or why it is private.
|
|
15
|
+
*/
|
|
16
|
+
describeAnonymousAccess(config, {
|
|
17
|
+
renderPath: {
|
|
18
|
+
// Anonymous reads see published pages only; signed-in users see drafts.
|
|
19
|
+
pages: 'the page routes, once the frontend renders them',
|
|
20
|
+
// formsPlugin opens form definitions so a page can render the fields.
|
|
21
|
+
forms: 'any page that embeds a form',
|
|
22
|
+
},
|
|
23
|
+
private: {
|
|
24
|
+
users: 'accounts',
|
|
25
|
+
'payload-mcp-api-keys': 'MCP keys are credentials; narrowed to admins in payload.config.ts',
|
|
26
|
+
'audit-events': 'who did what',
|
|
27
|
+
approvals: 'who signed off on what',
|
|
28
|
+
'form-submissions': 'what readers typed into a form',
|
|
29
|
+
integrations: 'connection settings and sync state',
|
|
30
|
+
'job-failures': 'background jobs that ran out of retries, with their errors',
|
|
31
|
+
'payload-kv': 'the key-value store Payload keeps',
|
|
32
|
+
'payload-locked-documents': 'who is editing what',
|
|
33
|
+
'payload-preferences': 'admin UI state',
|
|
34
|
+
'payload-migrations': 'the migration history',
|
|
35
|
+
},
|
|
36
|
+
})
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
+
import type { InngestFunction } from 'inngest'
|
|
1
2
|
import { serve } from 'inngest/next'
|
|
2
3
|
import { getPayload } from 'payload'
|
|
3
4
|
import config from '@/payload.config'
|
|
5
|
+
import { cacheTags } from '@/lib/cache-tags'
|
|
4
6
|
import { createInngestClient } from '@forumone/throughline-core'
|
|
5
7
|
import {
|
|
6
8
|
createAuditEventEchoFunction,
|
|
7
9
|
createExecuteScheduledPublishesFunction,
|
|
8
10
|
createExpireStaleApprovalsFunction,
|
|
11
|
+
createHealthcheckFailureHandler,
|
|
9
12
|
createHealthcheckFunction,
|
|
10
13
|
createPayloadReachableCheck,
|
|
11
14
|
createPublishAtScheduledTimeFunction,
|
|
12
15
|
createRevalidateOnPublishFunction,
|
|
16
|
+
createTerminalFailureHandler,
|
|
13
17
|
type ScheduledPublishRequest,
|
|
14
18
|
} from '@forumone/throughline-workflows'
|
|
15
19
|
import { getPublishingService } from '@forumone/throughline-publishing'
|
|
@@ -23,6 +27,13 @@ import {
|
|
|
23
27
|
const inngest = createInngestClient({ id: '{{projectName}}' })
|
|
24
28
|
const payload = await getPayload({ config })
|
|
25
29
|
|
|
30
|
+
/*
|
|
31
|
+
A workflow that runs out of retries is logged, recorded in the `job-failures`
|
|
32
|
+
collection, and posted to ERROR_WEBHOOK_URL if that is set. Passed to every
|
|
33
|
+
factory below: a factory without it fails silently into Inngest's dashboard.
|
|
34
|
+
*/
|
|
35
|
+
const onTerminalFailure = createTerminalFailureHandler({ payload })
|
|
36
|
+
|
|
26
37
|
const emailFunctions = getEmailFunctions(payload) ?? []
|
|
27
38
|
const formsFunctions = getFormsFunctions(payload) ?? []
|
|
28
39
|
/*
|
|
@@ -42,7 +53,23 @@ async function publishScheduled({ collection, id, reasoning }: ScheduledPublishR
|
|
|
42
53
|
|
|
43
54
|
const scheduledCollections = [{ slug: 'pages' }]
|
|
44
55
|
|
|
45
|
-
|
|
56
|
+
/*
|
|
57
|
+
Where each publishable collection's documents are served, so a publish
|
|
58
|
+
revalidates the right path. There are no defaults: a collection missing here
|
|
59
|
+
has its cache tags dropped but no path revalidated, and the run logs a warning.
|
|
60
|
+
Add an entry for every collection you give publishingPlugin.
|
|
61
|
+
*/
|
|
62
|
+
const urlBuilders = {
|
|
63
|
+
pages: (slug: string) => (slug === 'home' ? '/' : `/${slug}`),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
Typed with this app's own `InngestFunction.Any`. The registry is generic in what
|
|
68
|
+
`createFunctions` returns and defaults to `unknown`, because the platform never
|
|
69
|
+
touches those functions — and one `unknown` element leaves `serve()` unable to
|
|
70
|
+
type the whole array below.
|
|
71
|
+
*/
|
|
72
|
+
const integrationRegistry = getIntegrationRegistry<InngestFunction.Any>(payload)
|
|
46
73
|
const integrationContext = getIntegrationContext(payload)
|
|
47
74
|
const integrationFunctions =
|
|
48
75
|
integrationRegistry && integrationContext
|
|
@@ -54,13 +81,20 @@ const integrationFunctions =
|
|
|
54
81
|
export const { GET, POST, PUT } = serve({
|
|
55
82
|
client: inngest,
|
|
56
83
|
functions: [
|
|
57
|
-
createRevalidateOnPublishFunction({
|
|
84
|
+
createRevalidateOnPublishFunction({
|
|
85
|
+
inngest,
|
|
86
|
+
payload,
|
|
87
|
+
urlBuilders,
|
|
88
|
+
cacheTags,
|
|
89
|
+
onTerminalFailure,
|
|
90
|
+
}),
|
|
58
91
|
// On time: sleeps until each document's scheduled time.
|
|
59
92
|
createPublishAtScheduledTimeFunction({
|
|
60
93
|
inngest,
|
|
61
94
|
payload,
|
|
62
95
|
collections: scheduledCollections,
|
|
63
96
|
publish: publishScheduled,
|
|
97
|
+
onTerminalFailure,
|
|
64
98
|
}),
|
|
65
99
|
// The backstop, for a schedule whose wake-up event never arrived.
|
|
66
100
|
createExecuteScheduledPublishesFunction({
|
|
@@ -70,13 +104,16 @@ export const { GET, POST, PUT } = serve({
|
|
|
70
104
|
publish: publishScheduled,
|
|
71
105
|
schedule: '17 3 * * *',
|
|
72
106
|
overdueByMs: 60 * 60 * 1000,
|
|
107
|
+
onTerminalFailure,
|
|
73
108
|
}),
|
|
74
|
-
createExpireStaleApprovalsFunction({ inngest, payload }),
|
|
75
|
-
createAuditEventEchoFunction({ inngest }),
|
|
109
|
+
createExpireStaleApprovalsFunction({ inngest, payload, onTerminalFailure }),
|
|
110
|
+
createAuditEventEchoFunction({ inngest, onTerminalFailure }),
|
|
76
111
|
createHealthcheckFunction({
|
|
77
112
|
inngest,
|
|
78
113
|
payload,
|
|
79
114
|
checks: [createPayloadReachableCheck()],
|
|
115
|
+
onFailure: createHealthcheckFailureHandler({ payload }),
|
|
116
|
+
onTerminalFailure,
|
|
80
117
|
}),
|
|
81
118
|
...emailFunctions,
|
|
82
119
|
...formsFunctions,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Instrumentation } from 'next'
|
|
2
|
+
import {
|
|
3
|
+
buildRequestErrorReport,
|
|
4
|
+
describeErrorReporting,
|
|
5
|
+
reportError,
|
|
6
|
+
} from '@forumone/throughline-core/observability'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Where a server-side error goes.
|
|
10
|
+
|
|
11
|
+
`onRequestError` fires when Next captures an error in a Server Component
|
|
12
|
+
render, a Route Handler, a Server Action or the proxy — the one place that sees
|
|
13
|
+
all four. It does not see client-side errors; `error.tsx` and
|
|
14
|
+
`global-error.tsx` are the boundaries for those.
|
|
15
|
+
|
|
16
|
+
The report is logged first, always, and then posted to `ERROR_WEBHOOK_URL` if
|
|
17
|
+
it is set — a log drain, an alerting endpoint, a Slack incoming webhook, a
|
|
18
|
+
proxy in front of whichever tracker you choose. Request headers are copied
|
|
19
|
+
from an allowlist: `cookie` carries the Payload session, and must never leave.
|
|
20
|
+
|
|
21
|
+
The subpath import keeps the CMS out of this file. This hook runs when
|
|
22
|
+
something has already gone wrong, and loading Payload here would put the whole
|
|
23
|
+
CMS in the one path that has to work regardless.
|
|
24
|
+
*/
|
|
25
|
+
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
|
|
26
|
+
const report = buildRequestErrorReport(error, request, context)
|
|
27
|
+
console.error('[error]', JSON.stringify(report))
|
|
28
|
+
await reportError(report)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Once per server instance: say so if error reports are going nowhere. */
|
|
32
|
+
export function register(): void {
|
|
33
|
+
const status = describeErrorReporting()
|
|
34
|
+
if (!status.startsWith('error reporting: on')) console.warn('[boot]', status)
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Next cache tags, named in one place.
|
|
3
|
+
|
|
4
|
+
A tag has two ends: a reader that caches under it and a hook that drops it.
|
|
5
|
+
Nothing checks that they match, so both build their tags from this object —
|
|
6
|
+
`payload.config.ts` hands it to the revalidation hooks, the Inngest route hands
|
|
7
|
+
it to `createRevalidateOnPublishFunction`, and frontend code that caches a read
|
|
8
|
+
tags it from here too:
|
|
9
|
+
|
|
10
|
+
import { unstable_cache } from 'next/cache'
|
|
11
|
+
import { cacheTags } from '@/lib/cache-tags'
|
|
12
|
+
|
|
13
|
+
const getNavigation = unstable_cache(loadNavigation, ['navigation'], {
|
|
14
|
+
tags: [cacheTags.global('navigation')],
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
Change the scheme here and every end moves with it. Imported from the
|
|
18
|
+
package's `/cache-tags` subpath, which imports nothing, so frontend code that
|
|
19
|
+
uses it does not pull in Payload.
|
|
20
|
+
*/
|
|
21
|
+
import { createCacheTags } from '@forumone/throughline-workflows/cache-tags'
|
|
22
|
+
|
|
23
|
+
export const cacheTags = createCacheTags()
|