@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/dist/generator.d.ts.map +1 -1
  3. package/dist/generator.js +5 -0
  4. package/dist/generator.js.map +1 -1
  5. package/dist/post-install.d.ts.map +1 -1
  6. package/dist/post-install.js +10 -3
  7. package/dist/post-install.js.map +1 -1
  8. package/dist/templates/base/.env.example +32 -18
  9. package/dist/templates/base/CLAUDE.md +112 -0
  10. package/dist/templates/base/README.md +92 -7
  11. package/dist/templates/base/apps/web/e2e/site.ts +36 -0
  12. package/dist/templates/base/apps/web/e2e/smoke.spec.ts +52 -0
  13. package/dist/templates/base/apps/web/package.json.template +23 -7
  14. package/dist/templates/base/apps/web/playwright.config.ts +63 -0
  15. package/dist/templates/base/apps/web/scripts/check-migrations.ts +147 -0
  16. package/dist/templates/base/apps/web/scripts/lib/target.ts +95 -0
  17. package/dist/templates/base/apps/web/scripts/write-migration-snapshot.ts +119 -0
  18. package/dist/templates/base/apps/web/src/access/anonymousAccess.test.ts +36 -0
  19. package/dist/templates/base/apps/web/src/app/api/inngest/route.ts.template +41 -4
  20. package/dist/templates/base/apps/web/src/instrumentation.ts +35 -0
  21. package/dist/templates/base/apps/web/src/lib/cache-tags.ts +23 -0
  22. package/dist/templates/base/apps/web/src/lib/database.ts +158 -0
  23. package/dist/templates/base/apps/web/src/payload.config.ts.template +136 -19
  24. package/dist/templates/base/apps/web/vitest.config.ts +57 -0
  25. package/dist/templates/base/claude/hooks/gate.sh +45 -0
  26. package/dist/templates/base/claude/settings.json +16 -0
  27. package/dist/templates/base/github/scripts/check-generated.sh +44 -0
  28. package/dist/templates/base/github/workflows/ci.yml +143 -0
  29. package/dist/templates/base/gitignore +9 -0
  30. package/dist/templates/base/package.json.template +7 -1
  31. package/dist/templates/base/scripts/check-audit.ts +117 -0
  32. package/dist/templates/base/scripts/check-single-instance.ts +113 -0
  33. package/dist/templates/base/scripts/check-tested-packages.ts +163 -0
  34. package/dist/templates/base/turbo.json +16 -1
  35. package/dist/templates/with-reference-ds/design-system/scripts/build-manifest.ts +59 -29
  36. package/dist/templates/with-reference-ds/design-system/scripts/validate.ts +26 -1
  37. package/dist/templates/with-reference-ds/design-system/src/components/Divider/Divider.contract.ts +1 -0
  38. package/dist/versions.json +11 -11
  39. 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.83.0",
27
- "@payloadcms/next": "^3.83.0",
28
- "@payloadcms/plugin-form-builder": "3.83.0",
29
- "@payloadcms/richtext-lexical": "^3.83.0",
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.83.0",
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
- "typescript": "^5.6.0"
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
- const integrationRegistry = getIntegrationRegistry(payload)
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({ inngest, payload }),
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()