Merge branch 'develop' into security/upRateLimitBypassFix

This commit is contained in:
DMehaffy
2026-04-29 09:33:56 -07:00
committed by GitHub
1758 changed files with 134975 additions and 34029 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"setup-worktree": ["yarn install", "yarn build"]
}
+18 -3
View File
@@ -10,12 +10,27 @@ body:
validations:
required: true
- type: dropdown
id: packageManager
attributes:
label: Package Manager
description: Which package manager are you using?
multiple: false
options:
- npm
- yarn
- pnpm
- bun
- Other
validations:
required: true
- type: input
id: pmVersion
attributes:
label: NPM/Yarn/PNPM Version
description: The package manager and version you are using.
placeholder: NPM 10.0.0
label: Package Manager Version
description: The version of the package manager you selected above.
placeholder: 10.0.0
validations:
required: true
+3 -3
View File
@@ -1,6 +1,6 @@
# PR checker for status
This action checks a PR labels, milestone and status to validate it is ready for merging into main.
This action checks a PR labels, milestone and status to validate it is ready for merging.
> ❗️ When making changes to this code, make sure to run the build before committing. See [Development](#development) to know more.
@@ -12,8 +12,8 @@ This action checks a PR labels, milestone and status to validate it is ready for
- `flag: don't merge`
2. The PR should have one and only one `source: *` label.
3. The PR should have one and only one `issue-type: *` label.
4. The PR must have a milestone defined.
3. The PR should have one and only one `pr: *` label.
4. PRs targeting `develop` must have a milestone defined.
## Contributing
@@ -7,6 +7,10 @@ const github = require('@actions/github');
const core = require('@actions/core');
const action = require('../index');
beforeEach(() => {
jest.clearAllMocks();
});
test.each(action.BLOCKING_LABELS)('Test blocking labels %s', async (label) => {
github.context = {
payload: {
@@ -101,3 +105,72 @@ test('Test too many pr label', async () => {
setFailed.mockRestore();
});
test('Test missing milestone for develop PR', async () => {
github.context = {
payload: {
pull_request: {
base: {
ref: 'develop',
},
labels: [{ name: 'pr: enhancement' }, { name: 'source: core' }],
milestone: null,
},
},
};
const setFailed = jest.spyOn(core, 'setFailed');
await action();
expect(setFailed).toHaveBeenCalled();
expect(setFailed.mock.calls[0][0]).toBe(`The PR must have a milestone.`);
setFailed.mockRestore();
});
test('Test missing milestone for non-develop PR', async () => {
github.context = {
payload: {
pull_request: {
base: {
ref: 'main',
},
labels: [{ name: 'pr: enhancement' }, { name: 'source: core' }],
milestone: null,
},
},
};
const setFailed = jest.spyOn(core, 'setFailed');
await action();
expect(setFailed).not.toHaveBeenCalled();
setFailed.mockRestore();
});
test('Test develop PR with milestone', async () => {
github.context = {
payload: {
pull_request: {
base: {
ref: 'develop',
},
labels: [{ name: 'pr: enhancement' }, { name: 'source: core' }],
milestone: {
title: '5.0.0',
},
},
},
};
const setFailed = jest.spyOn(core, 'setFailed');
await action();
expect(setFailed).not.toHaveBeenCalled();
setFailed.mockRestore();
});
File diff suppressed because one or more lines are too long
+8 -9
View File
@@ -32,15 +32,14 @@ async function main() {
core.setFailed(`The PR must have one and only one 'pr:' label.`);
}
// NOTE: to avoid manual work, this is commented until we can set the workflow to trigger on pull_request milestone changes.
// ref: https://github.community/t/feature-request-add-milestone-changes-as-activity-type-to-pull-request/16778/16
/*
const milestone = context.payload.pull_request?.milestone;
const hasMilestone = !!milestone;
if (!hasMilestone) {
core.setFailed(`The PR must have a milestone.`);
}
*/
const baseRef = github.context.payload.pull_request?.base?.ref;
const milestone = github.context.payload.pull_request?.milestone;
const requiresMilestone = baseRef === 'develop';
const isMissingMilestone = milestone === null || milestone === undefined;
if (requiresMilestone === true && isMissingMilestone === true) {
core.setFailed(`The PR must have a milestone.`);
}
} catch (error) {
core.setFailed(error.message);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "check-pr-status",
"version": "5.30.1",
"version": "5.43.0",
"private": true,
"license": "MIT",
"main": "dist/index.js",
+6 -4
View File
@@ -1,8 +1,10 @@
## disable EE if options not set
if [[ -z "$RUN_EE" ]]; then
export STRAPI_DISABLE_EE=true
else
#!/usr/bin/env bash
## Align with `tests/utils/e2e-edition.ts`: explicit CE vs EE for the whole e2e run.
if [[ "${RUN_EE:-}" == "true" ]]; then
export STRAPI_E2E_EDITION=ee
export STRAPI_DISABLE_LICENSE_PING=true
else
export STRAPI_E2E_EDITION=ce
fi
jestOptions=($JEST_OPTIONS)
+2
View File
@@ -20,10 +20,12 @@ api:
- 'tests/api/**'
e2e:
- 'tests/scripts/run-e2e-tests.js'
- 'tests/scripts/run-tests.js'
- 'tests/e2e/**'
- 'playwright.base.config.js'
cli:
- 'tests/scripts/run-cli-tests.js'
- 'tests/scripts/run-tests.js'
- 'tests/cli/**'
docs:
- 'docs/**'
+2 -2
View File
@@ -21,9 +21,9 @@ jobs:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Configure git
+21 -3
View File
@@ -4,27 +4,45 @@ on:
pull_request:
types:
- opened
- edited
- synchronize
- reopened
- labeled
- unlabeled
- milestoned
- demilestoned
branches:
- main
- develop
- v4
permissions:
contents: read
pull-requests: read
jobs:
check-pr-status:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: ./.github/actions/check-pr-status
security-lockfile-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: ./.github/actions/security/lockfile
with:
allowedHosts: 'yarn'
check-package-versions:
name: Package version alignment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Install dependencies
uses: ./.github/actions/yarn-nm-install
- name: Check package versions
run: yarn version:check
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: 🧹 Cleanup
run: |
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Close stale issues
uses: actions/stale@v9
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
ascending: true # Start with oldest issues first
+2 -2
View File
@@ -14,10 +14,10 @@ jobs:
commitlint:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- uses: nrwl/nx-set-shas@v4
+55
View File
@@ -0,0 +1,55 @@
# SECURITY (read before editing):
# Do NOT add actions/checkout of the PR head or it could expose repo secrets.
name: Community PR Label
on:
pull_request_target:
types: [opened]
permissions: {}
jobs:
label:
runs-on: ubuntu-latest
if: github.event.pull_request.base.ref == github.event.repository.default_branch
permissions:
pull-requests: write
steps:
- name: Verify run context
run: |
if [ "${{ github.event_name }}" != "pull_request_target" ]; then
echo "::error::Expected pull_request_target, got ${{ github.event_name }}"
exit 1
fi
BASE_REF="${{ github.event.pull_request.base.ref }}"
DEFAULT_REF="${{ github.event.repository.default_branch }}"
if [ "$BASE_REF" != "$DEFAULT_REF" ]; then
echo "::error::Refusing to run when PR targets branch '$BASE_REF' (only default '$DEFAULT_REF' is allowed)."
exit 1
fi
echo "Running from default branch; PR head code is never executed."
- name: Check org membership and label
uses: actions/github-script@v7
with:
script: |
const { login } = context.payload.pull_request.user;
const association = context.payload.pull_request.author_association;
// Skip bot users
if (login && login.includes('[bot]')) {
core.info(`Skipped — ${login} is a bot`);
return;
}
if (!['MEMBER', 'OWNER', 'FIRST_TIME_CONTRIBUTOR', 'CONTRIBUTOR'].includes(association)) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: ['community']
});
core.info(`Added "community" label — ${login} has association "${association}"`);
} else {
core.info(`Skipped — ${login} is a ${association.toLowerCase()}`);
}
+3 -3
View File
@@ -27,8 +27,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@v4
uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 20
@@ -39,7 +39,7 @@ jobs:
run: yarn build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout Repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Fetch Merged PRs Between Branches
id: pr_diff
@@ -0,0 +1,78 @@
name: Find duplicate from issue id
on:
workflow_dispatch:
inputs:
issue_id:
description: 'Id of the issue to check for duplicates'
required: true
jobs:
deduplicate:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Check for duplicate issues
uses: anthropics/claude-code-action@v1
with:
prompt: |
Analyze this issue and check if it's a duplicate of other issues in the repository.
Issue: #${{ github.event.inputs.issue_id }}
Repository: ${{ github.repository }}
Your task:
1. Use mcp__github__get_issue to get details of the current issue (#${{ github.event.inputs.issue_id }})
2. Search for similar existing issues using mcp__github__search_issues with relevant keywords from the issue title and body
3. Compare the current issue with existing ones to identify potential duplicates
Criteria for duplicates:
- Same bug or error being reported
- Same feature request (even if worded differently)
- Same question being asked
- Same steps to reproduce the issue
- Issues describing the same root problem
If you find duplicates:
- Add a comment on the current issue linking to the other issue(s)
- Apply a "flag: duplicate" label to the new issue
- Be kind, Be polite and explain why it's a duplicate. Mention you might be making mistakes
- you must not close the issue
If it's NOT a duplicate:
- stop and exit
Here is a template for the comment:
Thanks for reporting this! This appears to be a duplicate of issue #[ISSUE_NUMBER] ([ISSUE_TITLE]).
Both issues report the same problem:
list the reasons why you think it's a duplicate
If you believe this issue is actually different or I've made an error, please feel free to reopen with additional details or comment on the existing issue. Labelling as duplicate, but happy to be corrected if needed!
---
*This action was performed automatically. If you believe this was done in error, please contact a human maintainer.*
Use these tools:
- mcp__github__get_issue: Get issue details
- mcp__github__search_issues: Search for similar issues
- mcp__github__list_issues: List recent issues if needed
- mcp__github__add_issue_comment: Add a comment if duplicate found
- mcp__github__update_issue: Add labels
Be thorough but efficient. Focus on finding true duplicates, not just similar issues.
anthropic_api_key: ${{ secrets.DUPLICATE_ISSUE_ANTHROPIC_API_KEY }}
claude_args: |
--allowedTools "mcp__github__get_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__add_issue_comment,mcp__github__update_issue,mcp__github__get_issue_comments"
+15 -24
View File
@@ -10,7 +10,6 @@ permissions:
actions: read
checks: read
contents: read
repository-projects: read
statuses: read
jobs:
@@ -167,8 +166,8 @@ jobs:
issue-number: ${{ github.event.issue.number }}
close-reason: 'not_planned'
# Redirect questions to community sources
- name: 'Comment: redirect question to community'
# Redirect questions to GitHub Discussions
- name: 'Comment: redirect question to discussions'
if: "${{ github.event.label.name == 'flag: question' }}"
uses: actions-cool/issues-helper@v3
with:
@@ -180,34 +179,26 @@ jobs:
Hello @${{ github.event.issue.user.login }},
I see you are wanting to ask a question that is not really a bug report,
Thank you for your interest in Strapi! However, this issue appears to be a question rather than a bug report. GitHub Issues are reserved for reproducible bug reports.
- questions should be directed to [our forum](https://forum.strapi.io) or our [Discord](https://discord.strapi.io)
- feature requests should be directed to our [feedback and feature request database](https://feedback.strapi.io)
Please ask your question in our [GitHub Discussions](https://github.com/strapi/strapi/discussions) where the community and team can help you out.
Please see the following contributing guidelines for asking a question [here](https://github.com/strapi/strapi/blob/master/CONTRIBUTING.md#reporting-an-issue).
You can also check out our [Discord](https://discord.strapi.io) for real-time help.
Thank you.
- name: 'Close: redirect question to community'
Thank you!
- name: 'Close: redirect question to discussions'
if: "${{ github.event.label.name == 'flag: question' }}"
uses: actions-cool/issues-helper@v3
with:
actions: 'close-issue'
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
close-reason: 'complete'
- name: assign issues to DevExp squad project
uses: actions/add-to-project@v0.4.1
close-reason: 'not_planned'
- name: 'Lock: redirect question to discussions'
if: "${{ github.event.label.name == 'flag: question' }}"
uses: actions-cool/issues-helper@v3
with:
project-url: https://github.com/orgs/strapi/projects/13
github-token: ${{ secrets.PROJECT_TRANSFER_TOKEN }}
labeled: 'source: cli, source: core:content-type-builder, source: core:core, source: core:data-transfer, source: core:database, source: core:strapi, source: core:utils, source: dependencies, source: marketplace, source: plugin:graphql, source: plugin:users-permissions, source: tooling, source: typescript, source: utils:upgrade, source: core:permissions, source: rbac, source: providers, source: core:openapi, source: strapi-ai'
label-operator: OR
- name: assign issues to Content squad project
uses: actions/add-to-project@v0.4.1
with:
project-url: https://github.com/orgs/strapi/projects/48
github-token: ${{ secrets.PROJECT_TRANSFER_TOKEN }}
labeled: 'source: core:admin, source: core:content-manager, source: core:content-releases, source: core:review-workflows, source: core:upload, source: plugin:color-picker, source: plugin:documentation, source: plugin:i18n, source: core:preview'
label-operator: OR
actions: 'lock-issue'
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.issue.number }}
lock-reason: 'off-topic'
+2 -2
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'strapi/strapi'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup npmrc
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- run: yarn
+76
View File
@@ -0,0 +1,76 @@
name: PR Review with Progress Tracking
# This workflow triggers an AI-powered code review when the 'AI Review' label is added to a pull request.
on:
pull_request:
types: [labeled]
jobs:
review-with-tracking:
if: ${{ github.event.label.name == 'AI Review' }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: PR Review with Progress Tracking
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.PR_REVIEW_ANTHROPIC_API_KEY }}
# Your custom review instructions
prompt: |
REPO: ${{ github.repository }}
PR NUMBER: ${{ github.event.pull_request.number }}
Perform a comprehensive code review with the following focus areas:
1. **Code Quality**
- Clean code principles and best practices
- Proper error handling and edge cases
- Code readability and maintainability
2. **Security**
- Check for potential security vulnerabilities
- Validate input sanitization
- Review authentication/authorization logic
3. **Performance**
- Identify potential performance bottlenecks
- Review database queries for efficiency
- Check for memory leaks or resource issues
4. **Testing**
- Verify adequate test coverage
- Review test quality and edge cases
- Check for missing test scenarios
5. **Documentation**
- Ensure code is properly documented
- Verify README updates for new features
- Check API documentation accuracy
6. **Impact Analysis**
- Identify and summarize the areas of the codebase impacted by these changes
- Assess potential ripple effects or regressions caused by this update
- Highlight any dependencies or modules that might require retesting or validation
7. **Bug Detection & Reliability**
- Identify any actual or potential bugs, including logic errors and incorrect assumptions
- Check for unintended side effects introduced by new changes
- Verify that input/output behavior aligns with expected functionality
- Flag areas with ambiguous or risky logic that may cause future regressions
- Recommend targeted fixes or refactoring where needed
Provide detailed feedback using inline comments for specific issues.
Use top-level comments for general observations or praise.
# Tools for comprehensive PR review
claude_args: |
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)"
+72
View File
@@ -0,0 +1,72 @@
name: 'Publish to npm'
on:
workflow_dispatch:
inputs:
version:
type: choice
description: 'The version you want to publish to NPM'
options:
- patch
- minor
- prepatch
- preminor
tag:
type: choice
description: 'NPM dist-tag'
default: 'experimental'
options:
- latest
- beta
- rc
- alpha
- experimental
permissions:
contents: write
id-token: write
jobs:
publish:
name: Publish packages
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v6
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: ${{ (inputs.tag == 'experimental') && 1 || 0 }}
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '20'
- run: yarn
- name: Publish packages (run publish scripts)
env:
TAG: ${{ inputs.tag }}
VERSION: ${{ inputs.version }}
REGISTRY: https://registry.npmjs.org/
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_ACTOR: ${{ github.actor }}
GITHUB_SHA: ${{ github.sha }}
run: |
set -euo pipefail
echo "Publish flow: $TYPE"
# Ensure registry is set
npm config set registry "$REGISTRY"
DIST_TAG="$TAG"
# Decide flow based on DIST_TAG: experimental => prerelease, otherwise release
if [ "$DIST_TAG" = "experimental" ]; then
echo "Running prerelease flow (DIST_TAG=experimental): ./scripts/pre-publish.sh"
export VERSION='0.0.0-experimental.${{ github.sha }}'
export DIST_TAG
./scripts/pre-publish.sh
else
echo "Running release flow: ./scripts/publish.sh (DIST_TAG=$DIST_TAG)"
fi
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'strapi/strapi'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup npmrc
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- run: yarn
+2 -2
View File
@@ -32,13 +32,13 @@ jobs:
with:
app-id: ${{ secrets.RELEASE_APP_ID }}
private-key: ${{ secrets.RELEASE_APP_SECRET }}
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0 # Fetch full history
- name: Setup npmrc
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- run: yarn
+2 -2
View File
@@ -16,10 +16,10 @@ jobs:
runs-on: ubuntu-latest
if: github.repository == 'strapi/strapi'
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Setup npmrc
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > .npmrc
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
- run: yarn
+126 -37
View File
@@ -34,7 +34,7 @@ jobs:
global: ${{ steps.filter.outputs.global }}
is_local: ${{ github.event.pull_request.head.repo.full_name == github.repository && !(github.actor == 'dependabot[bot]' || github.actor == 'dependabot-preview[bot]') }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 2 # need to check out previous 2 commits to see what has changed
- uses: dorny/paths-filter@v3
@@ -51,8 +51,8 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -69,8 +69,8 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
@@ -92,8 +92,8 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -109,10 +109,10 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # needed for nx-set-shas
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: nrwl/nx-set-shas@v4
@@ -132,10 +132,10 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # needed for nx-set-shas
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: nrwl/nx-set-shas@v4
@@ -161,10 +161,10 @@ jobs:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # needed for nx-set-shas
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: nrwl/nx-set-shas@v4
@@ -173,7 +173,37 @@ jobs:
- name: Monorepo build
uses: ./.github/actions/run-build
- name: Run tests
run: yarn nx affected --target=test:unit --nx-ignore-cycles
run: yarn nx affected --target=test:unit --nx-ignore-cycles -- --coverage
- name: Upload coverage artifacts
if: matrix.node == 24 # Only upload from one node version to avoid conflicts
uses: actions/upload-artifact@v4
with:
name: unit-coverage-reports
path: |
coverage/
packages/*/coverage/
retention-days: 1
unit_back_vitest:
if: needs.conditions.outputs.global == 'true' || needs.conditions.outputs.backend == 'true'
name: 'unit_back_vitest (node: ${{ matrix.node }})'
needs: [conditions, build]
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
uses: ./.github/actions/yarn-nm-install
- name: Monorepo build
uses: ./.github/actions/run-build
- name: Run Vitest unit tests
run: yarn test:unit:vitest
unit_front:
if: needs.conditions.outputs.global == 'true' || needs.conditions.outputs.frontend == 'true'
@@ -185,10 +215,10 @@ jobs:
matrix:
node: [20]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
fetch-depth: 0 # needed for nx-set-shas
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- uses: nrwl/nx-set-shas@v4
@@ -197,7 +227,16 @@ jobs:
- name: Monorepo build
uses: ./.github/actions/run-build
- name: Run tests
run: yarn nx affected --target=test:front --nx-ignore-cycles -- --runInBand
run: yarn nx affected --target=test:front --nx-ignore-cycles -- --runInBand --coverage
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
with:
name: front-coverage-reports
path: |
coverage/
packages/**/coverage/
retention-days: 1
e2e_ce:
if: needs.conditions.outputs.global == 'true' || needs.conditions.outputs.backend == 'true' || needs.conditions.outputs.frontend == 'true' || needs.conditions.outputs.e2e == 'true'
@@ -211,8 +250,8 @@ jobs:
project: ['chromium', 'webkit', 'firefox']
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Monorepo install
@@ -228,7 +267,7 @@ jobs:
runEE: false
jestOptions: '--project=${{ matrix.project }} --shard=${{ matrix.shard }}'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: failure()
with:
name: ce-${{ matrix.project }}--playwright-trace-${{ github.run_id }}-${{ github.job }}
@@ -259,9 +298,9 @@ jobs:
project: ['chromium', 'webkit', 'firefox']
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: 20
@@ -279,7 +318,7 @@ jobs:
runEE: true
jestOptions: '--project=${{ matrix.project }} --shard=${{ matrix.shard }}'
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v6
if: failure()
with:
name: ee-${{ matrix.project }}--playwright-trace-${{ github.run_id }}-${{ github.job }}
@@ -303,12 +342,13 @@ jobs:
name: 'CLI Tests (node: ${{ matrix.node }})'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v4
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
@@ -327,6 +367,7 @@ jobs:
needs: [conditions, build]
name: '[CE] API Integration (postgres, node: ${{ matrix.node }}, shard: ${{ matrix.shard }})'
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/4, 2/4, 3/4, 4/4]
@@ -348,8 +389,8 @@ jobs:
# Maps tcp port 5432 on service container to the host
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -367,6 +408,7 @@ jobs:
needs: [conditions, build]
name: '[CE] API Integration (mysql:latest, package: mysql2}, node: ${{ matrix.node }}, shard: ${{ matrix.shard }})'
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/4, 2/4, 3/4, 4/4]
@@ -387,8 +429,8 @@ jobs:
# Maps tcp port 5432 on service container to the host
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -406,12 +448,13 @@ jobs:
needs: [conditions, build]
name: '[CE] API Integration (sqlite, package: better-sqlite3, node: ${{ matrix.node }}, shard: ${{ matrix.shard }})'
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -432,6 +475,7 @@ jobs:
env:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/5, 2/5, 3/5, 4/5, 5/5]
@@ -453,8 +497,8 @@ jobs:
# Maps tcp port 5432 on service container to the host
- 5432:5432
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -475,6 +519,7 @@ jobs:
env:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/5, 2/5, 3/5, 4/5, 5/5]
@@ -495,8 +540,8 @@ jobs:
# Maps tcp port 5432 on service container to the host
- 3306:3306
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -517,12 +562,13 @@ jobs:
env:
STRAPI_LICENSE: ${{ secrets.strapiLicense }}
strategy:
fail-fast: false
matrix:
node: [20, 22, 24]
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
- name: Monorepo install
@@ -534,6 +580,47 @@ jobs:
dbOptions: '--dbclient=sqlite --dbfile=./tmp/data.db'
runEE: true
jestOptions: '--shard=${{ matrix.shard }}'
sonarqube:
name: SonarQube Analysis
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork && (needs.conditions.outputs.global == 'true' || needs.conditions.outputs.backend == 'true' || needs.conditions.outputs.frontend == 'true') }}
needs: [conditions, unit_back, unit_front]
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Download unit test coverage
uses: actions/download-artifact@v4
with:
name: unit-coverage-reports
path: unit-coverage
continue-on-error: true
- name: Download frontend test coverage
uses: actions/download-artifact@v4
with:
name: front-coverage-reports
path: front-coverage
continue-on-error: true
- name: List coverage files (debug)
run: find . -name "lcov.info" -type f
- name: SonarQube Scan
uses: SonarSource/sonarqube-scan-action@v7
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
- name: SonarQube Quality Gate check
id: sonarqube-quality-gate-check
uses: SonarSource/sonarqube-quality-gate-action@v1.2.0
timeout-minutes: 5
continue-on-error: true
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
test_result:
if: ${{ always() }}
@@ -547,6 +634,7 @@ jobs:
docs_build,
typescript,
unit_back,
unit_back_vitest,
unit_front,
e2e_ce,
e2e_ee,
@@ -557,6 +645,7 @@ jobs:
api_ee_pg,
api_ee_mysql,
api_ee_sqlite,
sonarqube,
]
steps:
- run: exit 1
+48
View File
@@ -0,0 +1,48 @@
name: Watch Stale Issues
on:
schedule:
- cron: '0 9 * * *' # Run daily at 9am UTC
workflow_dispatch: # Allows manual triggering from the Github UI
permissions:
issues: write # Required to close issues and post comments
contents: read # Needed by most GitHub Actions
jobs:
close-inactive:
runs-on: ubuntu-latest
steps:
- name: Watch stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
ascending: true # Start with oldest issues first
exempt-issue-labels: 'issue: security,severity: critical,severity: high,status: confirmed' # Don't close issues with any of these labels
exempt-all-milestones: true # Don't close issues that are part of a milestone
exempt-all-assignees: false # Close even if they are assigned
days-before-stale: 365 # Mark as stale after 1 year of no activity
days-before-close: 14 # Close 14 days after stale warning
days-before-pr-stale: -1 # Don't apply to PRs
days-before-pr-close: -1 # PRs disabled
operations-per-run: 100
stale-issue-message: |
👋 Hello, and thank you for helping us to make Strapi better!
This issue has had no activity for over **1 year**. As part of our effort to keep the issue tracker focused and manageable, we are reviewing older reports.
To ensure we spend our limited resources effectively, we are prioritizing:
- Active issues related to Strapi v5
- High severity issues that have been confirmed
- Issues with recent engagement
- Issues that are not duplicates of others
⚠️ This issue will be automatically closed in **14 days** unless there is new activity.
👉 If this is still relevant, please add a comment with any updates or context.
Thank you for helping us improve Strapi! 🙏
close-issue-message: |
This issue has been closed due to inactivity as part of our backlog review.
If the problem is still relevant (especially in **Strapi v5**), please let us know or open a new issue.
We truly appreciate your input and contributions 💜
+1 -2
View File
@@ -123,7 +123,6 @@ dist
############################
.vscode/
!.vscode/settings.json
front-workspace.code-workspace
.yarnrc
@@ -148,7 +147,6 @@ npm-debug.log
playwright-report
tests/e2e/playwright-storage-state.json
test-results
!tests/e2e/data/*.tar
tests/e2e/.env
!tests/cli/data/*.tar.gz
tests/cli/.env
@@ -157,6 +155,7 @@ tests/cli/.env
# AI tools
############################
CLAUDE.local.md
.claude
############################
# Strapi
+4
View File
@@ -5,3 +5,7 @@ build
.strapi
/.nx/cache
/.nx/workspace-data
# Strapi DTS dump fixtures: entire per-fixture dirs (JSONL, metadata.json, assets/metadata, assets/uploads, …)
tests/e2e/data/**
# Local DTS export trees (e.g. examples/complex/tmp-dts-export) — same layout; avoid Prettier EOF/newline churn
**/tmp-dts-export/**
+18
View File
@@ -0,0 +1,18 @@
{
"source": ["package.json", "**/package.json"],
"dependencyTypes": ["prod", "dev"],
"lintFormatting": false,
"lintSemverRanges": false,
"versionGroups": [
{
"label": "Yarn catalog references (catalog:) — version resolved from .yarnrc.yml [catalog] section, syncpack v13 classifies these as 'unsupported' specifiers",
"specifierTypes": ["unsupported"],
"isIgnored": true
},
{
"label": "Ignore non-exact (workspace, ranges, etc.)",
"specifierTypes": ["!exact"],
"isIgnored": true
}
]
}
-3
View File
@@ -1,3 +0,0 @@
{
"eslint.workingDirectories": [{ "mode": "auto" }]
}
+942
View File
File diff suppressed because one or more lines are too long
-925
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -4,4 +4,7 @@ nodeLinker: node-modules
preferInteractive: true
yarnPath: .yarn/releases/yarn-4.5.0.cjs
yarnPath: .yarn/releases/yarn-4.12.0.cjs
catalog:
vitest: ^4.0.18
+235
View File
@@ -0,0 +1,235 @@
# Strapi Monorepo — Agent Guide
Strapi is an open-source headless CMS.
Yarn workspaces + Nx monorepo. Node ≥20 ≤24, Yarn 4.
Target branch: `develop` (not `main`). All PRs go to `develop`.
---
## Repository Structure
```
packages/core/ # Framework: strapi, admin, database, content-manager, types, utils…
packages/plugins/ # Official plugins: users-permissions, i18n, graphql, documentation…
packages/providers/ # Email + upload provider implementations
packages/utils/ # Shared tooling: logger, eslint-config, tsconfig, vitest-config
packages/cli/ # CLI tools: create-strapi-app, cloud-cli
examples/ # Dev sandboxes only — not published, not for production fixes
docs/ # Contributor documentation (also at contributor.strapi.io)
tests/ # Integration, E2E, and CLI test infrastructure
```
The following are the most important packages (not exhaustive — run `yarn workspaces list` for the full set):
| Package | Description |
| ---------------------------------- | --------------------------------------------------------- |
| `@strapi/strapi` | Main framework entry point (Koa server) |
| `@strapi/admin` | React 18 admin dashboard |
| `@strapi/core` | Core business logic |
| `@strapi/database` | Database abstraction (MySQL, PostgreSQL, MariaDB, SQLite) |
| `@strapi/content-manager` | Content management UI |
| `@strapi/types` | Shared TypeScript type definitions |
| `@strapi/permissions` | RBAC engine |
| `@strapi/plugin-users-permissions` | JWT authentication |
---
## Architecture
- **`Strapi` class** — The DI container and central hub. Accessed as a `strapi` parameter injected through the factory pattern (e.g. `createService(strapi)`). Never use `global.strapi` — always prefer proper dependency injection. Provides `strapi.documents`, `strapi.db`, `strapi.log`, etc. Lifecycle: Register → Bootstrap → Start → Destroy (`start()` calls `load()` internally, which runs register + bootstrap).
- **Server / Admin split** — Koa.js HTTP server (`@strapi/strapi`) + React/Redux admin (`@strapi/admin`). Packages with both concerns export dual entry points: `strapi-server` (Node.js logic) and `strapi-admin` (UI components).
- **Document Service** — The primary high-level API for content (`strapi.documents`). Replaced the legacy Entity Service. Always use this for reading/writing content — never raw DB queries unless you're working inside `@strapi/database` itself.
- **Plugin system** — Plugins register routes, controllers, services, content types, and middleware via the same `strapi-server` / `strapi-admin` dual structure. Official plugins live in `packages/plugins/`.
- **Content Types** — Defined using a JSON-based notation (not JSON Schema spec). Each content type has a `schema.json` file — see any `packages/core/content-manager/server/src/content-types/` for examples. The database layer auto-generates tables from them. Never write raw migrations for content type changes.
- **EE / CE split** — Some features are Enterprise Edition only, gated at runtime. See EE toggles in the Testing section below.
- **`@strapi/types`** — Single source of truth for shared TypeScript types. Import from here; improve these types rather than duplicating locally.
---
## Monorepo Setup
```bash
# Initial setup (run once after cloning)
yarn install
yarn setup # clean + build all packages
```
---
## Development
```bash
# Run the dev sandbox
cd examples/getstarted
yarn develop # SQLite (default)
# Start non-memory databases (postgres/mysql)
docker-compose -f docker-compose.dev.yml up -d
DB=postgres yarn develop # PostgreSQL
DB=mysql yarn develop # MySQL
# Watch all packages + run sandbox with admin watch (two terminals)
yarn watch # terminal 1, repo root
cd examples/getstarted && yarn develop --watch-admin # terminal 2
```
---
## Build
```bash
yarn build # all packages (code + types)
yarn build:code # faster — skips .d.ts generation
yarn nx build @strapi/admin # single package
```
---
## Testing
### Unit tests (fastest — run first)
Unit test files live in `__tests__/` subdirectories within each package.
```bash
yarn test:unit
yarn test:unit:watch
yarn test:unit:update # update snapshots
```
### Frontend tests (admin panel)
Frontend test files also live in `__tests__/` within their respective packages.
```bash
yarn test:front # runs with IS_EE=true (EE features enabled)
yarn test:front:ce # runs with IS_EE=false (Community Edition only)
yarn test:front:update # update snapshots (EE)
yarn test:front:update:ce # update snapshots (CE)
```
### Type checking
```bash
yarn test:ts # all packages + front + back
```
### API integration tests
Integration tests live in `tests/api/`. Test apps are generated automatically — always regenerate with `yarn test:generate-app` rather than reusing a stale one (stale apps cause misleading failures).
```bash
yarn test:api # SQLite
yarn test:api --db=postgres
yarn test:api --db=mysql
yarn test:api -u # update snapshots
```
### CLI tests
CLI tests live in `tests/cli/`.
```bash
yarn test:cli
yarn test:cli:debug # with debug output
yarn test:cli:update # update snapshots
```
### E2E tests (Playwright)
E2E tests live in `tests/e2e/tests/` organized by domain (e.g. `admin`, `content-manager`, `i18n`).
```bash
yarn playwright install # one-time browser install
yarn test:e2e --setup --concurrency=1 # run all domains sequentially
yarn test:e2e --domains content-manager admin # run specific domains only
yarn test:e2e --concurrency=3 # run 3 domains in parallel
```
### EE toggles
- `IS_EE=true` — enables Enterprise features in frontend tests (`yarn test:front`)
- `RUN_EE=true` — enables Enterprise features in E2E tests
### Pre-PR checklist
All tests must pass before merging. Run at minimum:
```bash
yarn test:unit && yarn test:front && yarn test:ts && yarn lint && yarn prettier:check
```
E2E (`yarn test:e2e`) is required per CONTRIBUTING.md but slow — CI enforces it on every PR.
**When to add or update tests:** Always for bug fixes (reproduce the bug first). For features, add tests when they cover meaningful behaviour — not just to hit coverage numbers. When changing existing behaviour, update the affected tests to match.
---
## Quality Gates
### Commits
Must follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) (enforced by Husky `commit-msg` hook and CI via `commitlint`). Format:
```
<type>(<optional-scope>): <description>
```
Valid types: `feat` `fix` `chore` `ci` `docs` `enhancement` `test` `revert` `security` `future` `release`
**Examples:**
```bash
feat(content-manager): add bulk delete action
fix(database): preserve relation order during publish
chore(admin): migrate data-fetching to react-query
```
Use `yarn commit` for an interactive prompt. Run `yarn version:check` if you've touched any `package.json`.
### TypeScript
- Import types from `@strapi/types` — extend or improve them rather than duplicating locally.
- Never use `any` when a proper type exists or can be reasonably defined. Prefer `unknown` otherwise.
- Run `yarn test:ts` before pushing.
### Linting & Formatting
```bash
yarn lint # ESLint across all packages
yarn lint:fix # auto-fix
yarn format # Prettier (2-space indent, single quotes, semicolons, trailing commas, arrow parens, 100-char width, LF)
yarn prettier:check # check only
```
---
## Security
- Never commit secrets, credentials, or API keys.
- Never disable or weaken authentication/authorization checks.
- Use parameterized queries — never interpolate user input into raw SQL or database queries.
- Validate and sanitize all user input at controller/service boundaries.
- When working with EE-gated features, do not bypass license checks.
---
## PR Guidelines
- Branch from `develop`, target `develop` — never `main`.
- Link the issue you're fixing in the description.
- All tests must pass before merging.
- PR description must use this template:
- **What does it do?** — technical changes made
- **Why is it needed?** — problem being solved
- **How to test it?** — steps to reproduce and verify
- **Related issue(s)/PR(s)** — links
---
## Notes for Agents
- **`examples/`** apps are sandboxes only — use them to reproduce and test fixes, never commit changes to them unless specifically asked to do so.
- **Workspace deps** — internal `packages/` deps reference each other with pinned semver versions (e.g. `"5.42.0"`), not `workspace:*`. The `workspace:*` protocol is only used in `examples/` apps and some root devDeps.
- **Entity Service is deprecated** — always use the Document Service (`strapi.documents`) for content operations.
- **Lifecycle phases** — `strapi.isLoaded` must be `true` before accessing services. Plugins and DB are not available until after the `load()` phase.
+9 -2
View File
@@ -18,7 +18,7 @@ A Request For Comments has to be created on the [strapi/rfcs](https://github.com
## Code of Conduct
This project, and everyone participating in it, are governed by the [Strapi Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold it. Make sure to read the [full text](CODE_OF_CONDUCT.md) to understand which type of actions may or may not be tolerated.
This project, and everyone participating in it, are governed by the [Strapi Code of Conduct](https://github.com/strapi/strapi/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold it. Make sure to read the [full text](https://github.com/strapi/strapi/blob/main/CODE_OF_CONDUCT.md) to understand which type of actions may or may not be tolerated.
## Contributor License Agreement (CLA)
@@ -32,8 +32,14 @@ If you make contributions to our repositories on behalf of your company, we will
## Documentation
### Userland Documentation
Pull requests related to fixing documentation for the latest release should be directed towards the [documentation repository](https://github.com/strapi/documentation). Please follow the [documentation contributing guide](https://github.com/strapi/documentation/blob/main/CONTRIBUTING.md) for more information.
### Contributor Documentation
You can access the contributor documentation from (`./docs`) online at [contributor.strapi.io](https://contributor.strapi.io/).
## Bugs
Strapi is using [GitHub issues](https://github.com/strapi/strapi/issues) to manage bugs. We keep a close eye on them. Before filing a new issue, try to ensure your problem does not already exist.
@@ -60,6 +66,7 @@ The Strapi core team will review your pull request and either merge it, request
- `yarn test:front`
- `yarn test:e2e --setup --concurrency=1`
- you **_may_** need to install Playwright browsers first: `yarn playwright install`
- Enterprise (EE) e2e: set `STRAPI_LICENSE` in **`tests/e2e/.env`** (see [`docs/docs/guides/e2e/00-setup.md`](docs/docs/guides/e2e/00-setup.md))
- Make sure your code lints by running `yarn lint`.
- If your contribution fixes an existing issue, please make sure to link it in your pull request.
@@ -191,7 +198,7 @@ The types are based on our GitHub label, here are a subset:
- `doc` When writing documentation.
- `feat` When working on a feature.
You can see the complete list [here](https://github.com/strapi/strapi/blob/1cb6f95889ccaad897759cfa14d2804adeaeb7ee/.commitlintrc.ts#L11).
You can see the complete list [here](https://github.com/strapi/strapi/blob/develop/.commitlintrc.ts#L11).
#### Subject
+84 -86
View File
@@ -7,10 +7,9 @@
</a>
</p>
<h3 align="center">Open-source headless CMS, self-hosted or Cloud youre in control.</h3>
<h3 align="center">Open-source headless CMS, self-hosted or Cloud you're in control.</h3>
<p align="center">The leading open-source headless CMS, 100% JavaScript/TypeScript, flexible and fully customizable.</p>
<p align="center"><a href="https://cloud.strapi.io/signups?source=github1">Cloud</a> · <a href="https://strapi.io/demo?utm_campaign=Growth-Experiments&utm_source=strapi%2Fstrapi%20README.md">Try live demo</a></p>
<br />
<p align="center"><a href="https://docs.strapi.io">Docs</a> · <a href="https://strapi.io/cloud">Strapi Cloud</a> · <a href="https://feedback.strapi.io">Roadmap</a> · <a href="https://discord.strapi.io">Discord</a> · <a href="https://github.com/strapi/strapi/discussions">Discussions</a></p>
<p align="center">
<a href="https://www.npmjs.org/package/@strapi/strapi">
@@ -29,24 +28,28 @@
<br>
Strapi is an open-source, self-hosted headless CMS that lets developers build content APIs fast while giving content creators a friendly editing interface. Define your content models and Strapi generates a full API, ready to consume from any frontend, mobile app, or IoT device.
- Design content structures visually with the [**Content-Type Builder**](https://docs.strapi.io/cms/features/content-type-builder), no code required
- Auto-generated [**REST**](https://docs.strapi.io/cms/api/rest) & [**GraphQL**](https://docs.strapi.io/cms/plugins/graphql) APIs for every content type
- Granular [**Roles & Permissions**](https://docs.strapi.io/cms/features/users-permissions) out of the box
- Built-in [**Media Library**](https://docs.strapi.io/cms/features/media-library), [**Internationalization (i18n)**](https://docs.strapi.io/cms/features/internationalization), and [**Draft & Publish**](https://docs.strapi.io/cms/features/draft-and-publish)
- First-class **TypeScript** support with flexible database options (SQLite, PostgreSQL, MySQL, MariaDB)
- Extensible [**plugin system**](https://docs.strapi.io/cms/plugins-development/developing-plugins) and customizable admin dashboard
> Explore all features at **[strapi.io/features](https://strapi.io/features)**
> **Strapi AI** — Automate content modeling, media alt text, and translations with Strapi's built-in AI layer. [Learn more](https://strapi.io/ai)
### How Strapi handles requests
Every incoming request flows through a layered backend architecture: **Routes → Middlewares → Controllers → Services**.
<p align="center">
<a href="https://strapi.io">
<img src="https://raw.githubusercontent.com/strapi/strapi/main/public/assets/admin-demo.gif" alt="Administration panel" />
</a>
<img src="https://docs.strapi.io/img/assets/backend-customization/diagram-routes.png" alt="Strapi backend request flow: Routes, Middlewares, Controllers, and Services" />
</p>
<br>
Strapi Community Edition is a free and open-source headless CMS enabling you to manage any content, anywhere.
- **Self-hosted or Cloud**: You can host and scale Strapi projects the way you want. You can save time by deploying to [Strapi Cloud](https://cloud.strapi.io/signups?source=github1) or deploy to the hosting platform you want\*\*: AWS, Azure, Google Cloud, DigitalOcean.
- **Modern Admin Panel**: Elegant, entirely customizable and a fully extensible admin panel.
- **Multi-database support**: You can choose the database you prefer: PostgreSQL, MySQL, MariaDB, and SQLite.
- **Customizable**: You can quickly build your logic by fully customizing APIs, routes, or plugins to fit your needs perfectly.
- **Blazing Fast and Robust**: Built on top of Node.js and TypeScript, Strapi delivers reliable and solid performance.
- **Front-end Agnostic**: Use any front-end framework (React, Next.js, Vue, Angular, etc.), mobile apps or even IoT.
- **Secure by default**: Reusable policies, CORS, CSP, P3P, Xframe, XSS, and more.
- **Powerful CLI**: Scaffold projects and APIs on the fly.
> Learn more about backend customization in the [official docs](https://docs.strapi.io/cms/backend-customization).
## Getting Started
@@ -54,95 +57,70 @@ Strapi Community Edition is a free and open-source headless CMS enabling you to
### ⏳ Installation
Install Strapi with this **Quickstart** command to create a Strapi project instantly:
- (Use **yarn** to install the Strapi project (recommended). [Install yarn with these docs](https://yarnpkg.com/lang/en/docs/install/).)
Use the **Quickstart** command below to create a new Strapi project instantly:
```bash
yarn create strapi
```
**or**
- (Using npx to install the Strapi project.)
```bash
npx create-strapi@latest
npx create-strapi@latest my-project
```
This command generates a brand new project with the default features (authentication, permissions, content management, content type builder & file upload).
Enjoy 🎉
> Full installation options (including TypeScript, `--quickstart`, etc.) in the **[CLI installation docs](https://docs.strapi.io/cms/installation/cli#creating-a-strapi-project)**
### 🖐 Requirements
### Requirements
Complete installation requirements can be found in the documentation under <a href="https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/deployment.html">Installation Requirements</a>.
> Hardware & software requirements (OS, Node.js, databases) at **[Requirements docs](https://docs.strapi.io/cms/deployment#hardware-and-software-requirements)**
**Supported operating systems**:
## Docker
| OS | Recommended | Minimum |
| --------------- | ----------- | ---------- |
| Ubuntu | 24.04 | LTS |
| Debian | 11 | LTS |
| RHEL | 9 | LTS |
| macOS | 14 | 12 |
| Windows Desktop | 11 | 10 |
| Windows Server | No Support | No Support |
| Docker | N/A | N/A |
Strapi doesn't ship official Docker images, so you build your own from your project. The fastest way to get started is with the community CLI tool:
(Please note that Strapi may work on other operating systems, but these are not tested nor officially supported at this time.)
```bash
npx @strapi-community/dockerize@latest
```
**Node:**
This generates a `Dockerfile` and `docker-compose.yml` tailored to your project. See the [strapi-tool-dockerize](https://github.com/strapi-community/strapi-tool-dockerize) repo for more details.
Strapi only supports maintenance and LTS versions of Node.js. Please refer to the <a href="https://nodejs.org/en/about/releases/">Node.js release schedule</a> for more information. NPM versions installed by default with Node.js are supported. Generally it's recommended to use yarn over npm where possible.
> Dockerfiles, Docker Compose examples, production builds at **[Docker installation docs](https://docs.strapi.io/cms/installation/docker)**
| Strapi Version | Recommended | Minimum |
| --------------- | ----------- | ------- |
| 5.31.0 and up | 24.x | 20.x |
| 5.0.0 to 5.30.1 | 20.x | 18.x |
| 4.14.5 and up | 20.x | 18.x |
| 4.11.0 and up | 18.x | 16.x |
| 4.3.9 to 4.10.x | 18.x | 14.x |
| 4.0.x to 4.3.8 | 16.x | 14.x |
## Deploy to Strapi Cloud
**Database:**
The fastest way to go from local to production. Strapi Cloud is the official managed hosting platform with zero DevOps, a built-in database, media library, and CDN.
| Database | Recommended | Minimum |
| ---------- | ----------- | ------- |
| MySQL | 8.0 | 8.0 |
| MariaDB | 11.2 | 10.3 |
| PostgreSQL | 16.0 | 14.0 |
| SQLite | 3 | 3 |
> [**Deploy now**](https://cloud.strapi.io)
**We recommend always using the latest version of Strapi stable to start your new projects**.
## LaunchPad
## Features
The official demo template combining Strapi with Next.js to get you started quickly.
- **Content Types Builder**: Build the most flexible publishing experience for your content managers, by giving them the freedom to create any page on the go with [fields](https://docs.strapi.io/user-docs/content-manager/writing-content#filling-up-fields), components and [Dynamic Zones](https://docs.strapi.io/user-docs/content-manager/writing-content#dynamic-zones).
- **Media Library**: Upload your images, videos, audio or documents to the media library. Easily find the right asset, edit and reuse it.
- **Internationalization**: The Internationalization (i18n) plugin allows Strapi users to create, manage and distribute localized content in different languages, called "locales"
- **Role Based Access Control**: Create an unlimited number of custom roles and permissions for admin and end users.
- **GraphQL or REST**: Consume the API using REST or GraphQL
> [**LaunchPad template**](https://github.com/strapi/LaunchPad)
You can unlock additional features such as SSO, Audit Logs, Review Workflows in [Strapi Cloud](https://cloud.strapi.io/login?source=github1) or [Strapi Enterprise](https://strapi.io/enterprise?source=github1).
### Try live demo
**[See more on our website](https://strapi.io/overview)**.
See for yourself what's under the hood by getting access to a [hosted Strapi project](https://strapi.io/demo) with sample data — [try it live](https://strapi.io/demo) or [pull locally](https://github.com/strapi/LaunchPad).
## Contributing
## Repositories
Please read our [Contributing Guide](./CONTRIBUTING.md) before submitting a Pull Request to the project.
| Repository | Description |
| ------------------------------------------------------------------- | --------------------------------------------- |
| [**strapi/strapi**](https://github.com/strapi/strapi) | Core monorepo, the CMS itself |
| [**strapi/design-system**](https://github.com/strapi/design-system) | Strapi Design System, React component library |
| [**strapi/LaunchPad**](https://github.com/strapi/LaunchPad) | Demo app: Strapi + Next.js |
## Community support
## Contributing & Community
For general help using Strapi, please refer to [the official Strapi documentation](https://docs.strapi.io). For additional help, you can use one of these channels to ask a question:
Strapi is community-built and open source. For help, feedback, or to get involved:
- [Discord](https://discord.strapi.io) (For live discussion with the Community and Strapi team)
- [GitHub](https://github.com/strapi/strapi) (Bug reports, Contributions)
- [Community Forum](https://forum.strapi.io) (Questions and Discussions)
- [Feedback section](https://feedback.strapi.io) (Roadmap, Feature requests)
- [Twitter](https://twitter.com/strapijs) (Get the news fast)
- [Facebook](https://www.facebook.com/Strapi-616063331867161)
- [YouTube Channel](https://www.youtube.com/strapi) (Learn from Video Tutorials)
- **[Discord](https://discord.strapi.io)**: Live discussion with the community and Strapi team
- **[GitHub Discussions](https://github.com/strapi/strapi/discussions)**: Ask questions, share projects, get feedback
- **[GitHub](https://github.com/strapi/strapi)**: Bug reports and contributions
- **[Contributing Guide](https://contributor.strapi.io/)**: How to contribute code, docs, and translations
- **[Community Content](https://github.com/strapi/community-content)**: Showcase, tutorials, starters, and templates
- **[Feedback](https://feedback.strapi.io)**: Roadmap and feature requests
- **[Twitter](https://twitter.com/strapijs)**: Get the news fast
- **[YouTube Channel](https://www.youtube.com/strapi)**: Learn from video tutorials
For general help, refer to the [official Strapi documentation](https://docs.strapi.io). New to open source? Check out [How to Contribute to Open Source](https://opensource.guide/).
## Migration
@@ -156,14 +134,34 @@ Check out our [roadmap](https://feedback.strapi.io) to get informed of the lates
See our dedicated [repository](https://github.com/strapi/documentation) for the Strapi documentation, or view our documentation live:
- [Developer docs](https://docs.strapi.io/developer-docs/latest/getting-started/introduction.html)
- [User guide](https://docs.strapi.io/user-docs/latest/getting-started/introduction.html)
- [Cloud guide](https://docs.strapi.io/cloud/intro)
- [CMS docs](https://docs.strapi.io/cms/intro)
- [Cloud docs](https://docs.strapi.io/cloud/intro)
## Try live demo
## Security
See for yourself what's under the hood by getting access to a [hosted Strapi project](https://strapi.io/demo) with sample data.
If you discover a security issue, please report it responsibly. See our [Security Policy](https://github.com/strapi/strapi/blob/main/SECURITY.md) for our disclosure process and contact info.
## About Strapi
- **[Company Handbook](https://handbook.strapi.io)**: Our values, how we work, and more
- **[About Us](https://strapi.io/about-us)**: The story of Strapi
[Contact us](https://strapi.io/contact)
## Support Strapi
If Strapi is useful to you, give us a star. It helps more than you think!
## Thanks to All Contributors
Thank you to everyone who has contributed code, reported issues, and helped shape Strapi.
[![Strapi contributors](https://contrib.rocks/image?repo=strapi/strapi&max=400&columns=20)](https://github.com/strapi/strapi/graphs/contributors)
## License
See the [LICENSE](./LICENSE) file for licensing information.
---
[Website](https://strapi.io) · [Docs](https://docs.strapi.io) · [Blog](https://strapi.io/blog) · [Twitter](https://twitter.com/strapijs) · [LinkedIn](https://www.linkedin.com/company/strapi/)
+12 -12
View File
@@ -2,20 +2,20 @@
## Supported Versions
As of September 2025 (and until this document is updated), only the v4.x.x and v5.x.x _GA_ or _STABLE_ releases of Strapi are supported for updates and bug fixes. Any previous versions are currently not supported and users are advised to use them "at their own risk".
As of October 2025 (and until this document is updated), v5.x.x _GA_ or _STABLE_ releases of Strapi are supported for updates and bug fixes. Strapi v4.x.x _GA_ or _STABLE_ are now only supported for security updates. Any previous versions are currently not supported and users are advised to use them "at their own risk".
**Note**: The v4.x.x LTS version will only receive high/critical severity fixes until April 2026. Any Medium/Low severity issues will not be fixed unless specific exceptions are made.
**Note**: The v4.x.x LTS version will only receive high/critical severity **SECURITY** fixes until April 2026. No further bug fixes releases will be made.
| Version | Release Tag | Support Starts | Support Ends | Security Updates Until | Notes |
| ------- | ----------- | -------------- | -------------- | ---------------------- | ------------------ |
| 5.x.x | GA / Stable | September 2024 | Further Notice | Further Notice | LTS |
| 5.x.x | RC | N/A | September 2024 | N/A | End Of Life |
| 5.x.x | Beta | N/A | N/A | N/A | End Of Life |
| 5.x.x | Alpha | N/A | N/A | N/A | End Of Life |
| 4.x.x | GA / Stable | November 2021 | October 2025 | April 2026 | Maintanence Period |
| 4.x.x | Beta | N/A | N/A | N/A | End Of Life |
| 4.x.x | Alpha | N/A | N/A | N/A | End Of Life |
| 3.x.x | N/A | N/A | N/A | N/A | End Of Life |
| Version | Release Tag | Support Starts | Support Ends | Security Updates Until | Notes |
| ------- | ----------- | -------------- | -------------- | ---------------------- | ------------- |
| 5.x.x | GA / Stable | September 2024 | Further Notice | Further Notice | LTS |
| 5.x.x | RC | N/A | September 2024 | N/A | End Of Life |
| 5.x.x | Beta | N/A | N/A | N/A | End Of Life |
| 5.x.x | Alpha | N/A | N/A | N/A | End Of Life |
| 4.x.x | GA / Stable | November 2021 | October 2025 | April 2026 | Security Only |
| 4.x.x | Beta | N/A | N/A | N/A | End Of Life |
| 4.x.x | Alpha | N/A | N/A | N/A | End Of Life |
| 3.x.x | N/A | N/A | N/A | N/A | End Of Life |
## Reporting a Vulnerability
+6 -1
View File
@@ -1,15 +1,20 @@
# Strapi contributor documentation
> [!NOTE]
> If you are looking for the official Strapi documentation, it is available [here](https://docs.strapi.io)
This documentation is a contributor documentation made for anyone that wants to contribute to the project.
To run the documentation website, follow the instructions below.
Otherwise, you can also access the documentation online at [contributor.strapi.io](https://contributor.strapi.io/).
## Getting Started
### Installation
```
$ yarn
$ yarn install
```
### Local Development
+6
View File
@@ -618,6 +618,12 @@ TODO
TODO
:::
### `middlewares`
:::info
TODO
:::
### `plugins`
:::info
+1 -1
View File
@@ -76,7 +76,7 @@ const config = container.get('config');
// config.db === 'sqlite'
```
⚠️ If the **resolver**, used in the [register function](#containerregistername-resolver), is a **function**, the value will be the result of this resolver function with `args` as parameter on the first call to `get`.
⚠️ If the **resolver**, used in the register function, is a **function**, the value will be the result of this resolver function with `args` as parameter on the first call to `get`.
Please pay attention that the resolver result value isn't awaited. So if resolver returns a promise, the value stored will be a promise.
@@ -172,5 +172,4 @@ The Review Workflow feature is currently included as a core feature within the S
- https://docs.strapi.io/user-docs/settings/review-workflows
- https://docs.strapi.io/user-docs/content-type-builder/creating-new-content-type#creating-a-new-content-type
- https://docs.strapi.io/user-docs/users-roles-permissions/configuring-administrator-roles#plugins-and-settings
- [Content Manager Review Workflows](../../content-manager/02-review-workflows.mdx)
- [Content Type Builder Review Workflows](../../content-type-builder/01-review-workflows.mdx)
- [Content Manager Review Workflows](../../content-manager/features/review-workflows)
@@ -61,6 +61,14 @@ Is a wrapper around the [`hasPermissions`](#haspermissions) function which calls
list of permissions to assertain if a user can do a specific action. This hook is typically used with plugin permissions alongside the
global permissions object generated from the [`useRBACProvider`](#userbacprovider) hook which can either be passed or will be accessed internally.
### `hasPermissions`
Function that checks if a user has the required permissions.
### `useRBACProvider`
Hook that provides the global permissions object.
Because it's fetching, we also provide a `isLoading` state which can be used to show a loading state while the permissions are being fetched.
If the hook is unmouted before the fetch request completes, the request will be cancelled via an [`AbortController`](https://developer.mozilla.org/en-US/docs/Web/API/AbortController).
@@ -119,6 +119,16 @@ Key files:
- Routes: `packages/plugins/users-permissions/server/routes/content-api/auth.js`
- JWT service: `packages/plugins/users-permissions/server/services/jwt.js`
## Session Revocation on Credential Changes
For security, **password changes and resets automatically invalidate all active refresh/session tokens** across all devices:
- **Admin**: When an admin user changes their password via `PUT /admin/users/me` (with `currentPassword` and `password`), all admin sessions are revoked, including the current session. The user must re-authenticate.
- **Admin**: When an admin resets their password via `POST /admin/reset-password`, all existing admin sessions are revoked before issuing a new session.
- **Content API (refresh mode)**: When a user changes their password via `POST /api/auth/change-password` or resets it via `POST /api/auth/reset-password`, all users-permissions sessions are revoked. A new refresh token is issued for the current request.
This behavior ensures that compromised refresh tokens cannot be used after a password change, mitigating persistent session hijacking attacks.
## Notes
- Access tokens are always used in the `Authorization` header as `Bearer <token>`.
@@ -8,7 +8,7 @@ tags:
## What is the Content Manager?
The content-manager is a plugin that allows users to write / update & delete their content, it's currently held within the `@strapi/admin` package, but from V5 will be removed back to to its own plugin. At its very basic form, the CM is just a table & a few forms. There are a few public APIs to manipulate these forms & tables as well as some universal hooks exported for user's to additionally interact with within their own plugins outside of the CM plugin & within.
The content-manager is a plugin that allows users to write / update & delete their content, it's currently held within the `@strapi/admin` package, but from V5 will be removed back to its own plugin. At its very basic form, the CM is just a table & a few forms. There are a few public APIs to manipulate these forms & tables as well as some universal hooks exported for user's to additionally interact with within their own plugins outside of the CM plugin & within.
## Sections
@@ -73,7 +73,7 @@ type EditFieldLayout = {
}[Attribute.Kind];
```
The excluded `type` values are unique to the content-manager and as such aren't expected to be rendered as universal form inputs. This data-structure is passed to the `InputRenderer` component to render any form input (previously known as `GenericInputs` from the helper-plugin). Notice how these inputs don't recieve their `value`, `onChange` or `error` prop. These are extracted from the `Form` using `useField(name)`:
The excluded `type` values are unique to the content-manager and as such aren't expected to be rendered as universal form inputs. This data-structure is passed to the `InputRenderer` component to render any form input (previously known as `GenericInputs` from the helper-plugin). Notice how these inputs don't receive their `value`, `onChange` or `error` prop. These are extracted from the `Form` using `useField(name)`:
```ts
export const StringInput = forwardRef<HTMLInputElement, InputProps>(
@@ -0,0 +1,221 @@
---
title: Blocks editor
description: The modern JSON-based rich text editor
tags:
- content-manager
---
The blocks editor is a modern text editor based on the [Slate.js library](https://docs.slatejs.org/). It is designed as the successor to the classic markdown editor, and can only be used within the content-manager.
## Data format
### Why JSON
While the markdown editor stores content as a string, Blocks stores it as a JSON object. Since Strapi is headless, we want a format that makes it easy to map content across different platforms and offer a good experience for non-web use cases too. In the case of React frontends, JSON also means we don't need to rely on `dangerouslySetInnerHTML` to render the formatted content.
### Slate-based schema
The blocks editor schema is based on Slate.js, which allows us to design our own custom schema structure. We chose Slate in part because its JSON format remains human-readable. This was a key element to ensure the data could be rendered in any frontend where official or third-party libraries may not be available.
Our Blocks schema is made of the following elements:
- Block nodes: they're at the root of the document JSON: Paragraphs, images, headings, lists, code blocks...
- Inline nodes: they're children of block nodes, and can have children. Inline nodes include text nodes, and links.
- Text leaves: they're children of inline nodes, and contain the actual text content. One specificity of our design compared to most Slate implementations is that they must have a `"type": "text"` entry in addition to the standard `text` property.
- Modifiers: they're variations of text nodes that can be applied to text content. They include bold, italic, underline, and strikethrough. They can be combined.
With these elements combined, here's what the content for a sample Blocks attribute looks like:
```json
[
{
"type": "heading",
"children": [
{
"type": "text",
"text": "I am a heading"
}
],
"level": 1
},
{
"type": "paragraph",
"children": [
{
"type": "text",
"text": "Nice content here. Isn't it? Some of it can be "
},
{
"type": "text",
"text": "bold",
"bold": true
},
{
"type": "text",
"text": ", "
},
{
"type": "text",
"text": "italic",
"italic": true
},
{
"type": "text",
"text": ", or "
},
{
"type": "text",
"text": "both",
"bold": true,
"italic": true
},
{
"type": "text",
"text": "."
}
]
},
{
"type": "list",
"format": "unordered",
"children": [
{
"type": "list-item",
"children": [
{
"type": "text",
"text": "first list item"
}
]
},
{
"type": "list-item",
"children": [
{
"type": "text",
"text": "sub list"
}
]
},
{
"type": "list",
"format": "unordered",
"indentLevel": 1,
"children": [
{
"type": "list-item",
"children": [
{
"type": "text",
"text": "sub 1"
}
]
},
{
"type": "list-item",
"children": [
{
"type": "text",
"text": "sub 2"
}
]
}
]
}
]
}
]
```
## Components structure
<img
src="/img/content-manager/blocks/components-structure.png"
alt="a diagram showing how the blocks input is split into components"
/>
As shown in the diagram, the Blocks input is split into several components:
- `BlocksInput` is the input ready to be used in the content manager edit view. Besides the editor, it is responsible for rendering the label, label actions (like the i18n icon), as well as error and hint messages.
- `BlocksEditor` is the root of the Slate editor. It provides the context for all the stateful parts of the editor.
- `BlocksToolbar` is the toolbar that appears above the editor. It provides buttons for formatting the text, as well as a button to insert a new block.
- `BlocksContent` is the WYSIWYG editor that allows users to write and view their formatted content.
## Blocks and modifiers management
A key goal of the blocks implementation is that it should be driven by a declarative list of blocks and modifiers. Each block and each modifier should manage its own rendering, logic and behavior. The editor itself (whether it's the toolbar or the content) should not contain any logic targeting specific blocks or modifiers. It should remain agnostic.
This has several upsides. The logic for each block or modifier is self-contained within its own file, making it easier to grasp and edit. It keeps the editor's code lean and avoids spaghetti implementations. It lets us manage blocks from several entry points: the toolbar's dropdown, a Notion-style `/` to open a blocks popover...
And importantly, it opens the door for a [custom](https://github.com/strapi/strapi/pull/24427) [blocks](https://feedback.strapi.io/customization/p/add-ability-to-extend-strapis-rich-text-editor-with-custom-slate-elements) and custom modifiers API, letting users extend the editor with their own building blocks.
### Block registration
A block is registered in the editor as an object of always the same shape. For a single "type", there can be several variations, all of which will have their own object. For example, Heading 1 and Heading 2 both have `heading` type, but are displayed separately in the editor, and therefore are registered separately.
| Property | Description |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **renderElement**<br />`(props: RenderElementProps) => JSX.Element` | Displays the block inside `BlocksContent`. Make sure to spread `attributes` on the root (that's how Slate makes it editable), and to render `children` so that inline nodes and text leaves can be rendered. |
| **icon**<br />`React.ComponentType` | A visual cue to identify the block. Use icons from the design system. |
| **label**<br />`MessageDescriptor` | A translation object to display what the block is called. |
| **isInBlocksSelector**<br />`boolean` | Whether the block should appear in the blocks selector dropdown. In almost all cases, this is `true`. The main exception is list items, as they are hidden under their list parent. |
| **dragHandleTopMargin**<br />`CSSProperties['marginTop']` | Adjusts the vertical alignment of the grip icon used to reorder nodes. |
| **matchNode**<br />`(node: BlocksNode) => boolean` | Returns a boolean that indicates whether a node is of the given block type. |
| **handleConvert**<br />`(editor: Editor) => void` | Customizes the logic run when transforming the currently selected node into the given node type. It generally involves setting the `type` property and clearing unwanted properties. The `baseHandleConvert` util manages just that. |
| **handleEnterKey**<br />`(editor: Editor) => void` | Customizes the logic ran when the user presses enter. By default, it creates a paragraph under the current node and selects it. For blocks that may have multi-line content, such as code blocks, a common pattern is to replicate this behavior when the user presses enter twice. |
| **handleBackspaceKey**<br />`(editor: Editor, event: KeyboardEvent) => void` | Customizes the logic ran when the user presses backspace. Useful for blocks that need special behavior when deleting content at the start of a block. |
| **handleTab**<br />`(editor: Editor) => void` | Customizes the logic ran when the user presses tab. Useful for blocks like lists that support indentation. |
| **snippets**<br />`string[]` | Strings that can trigger a transformation into the given block after pressing space. |
```tsx
const headingBlocks = {
'heading-one': {
renderElement: (props) => <H1 {...props.attributes}>{props.children}</H1>,
icon: HeadingOne,
label: {
id: 'components.Blocks.blocks.heading1',
defaultMessage: 'Heading 1',
},
matchNode: (node) => node.type === 'heading' && node.level === 1,
isInBlocksSelector: true,
handleConvert: (editor) => baseHandleConvert(editor, { type: 'heading', level: 1 }),
snippets: ['#'],
dragHandleTopMargin: '14px',
},
};
```
### Modifier registration
A modifier is registered in the editor as an object with a consistent shape.
| Property | Description |
| ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| **renderLeaf**<br />`(children: JSX.Element \| string) => JSX.Element` | Displays the modified text inside `BlocksContent`. This wraps the children in the appropriate styled component (e.g., `<BoldText>` for bold). |
| **icon**<br />`React.ComponentType` | A visual cue to identify the modifier. Use icons from the design system. |
| **label**<br />`MessageDescriptor` | A translation object to display what the modifier is called. |
| **isValidEventKey**<br />`(event: KeyboardEvent) => boolean` | Returns a boolean that indicates whether a keyboard event should trigger this modifier. Used for keyboard shortcuts (e.g., `Ctrl+B` for bold). |
| **checkIsActive**<br />`(editor: Editor) => boolean` | Returns a boolean that indicates whether the modifier is currently active on the selection. |
| **handleToggle**<br />`(editor: Editor) => void` | Customizes the logic ran when toggling the modifier on or off. |
```tsx
const modifiers = {
bold: {
renderLeaf: (children) => <BoldText>{children}</BoldText>,
icon: Bold,
label: {
id: 'components.Blocks.modifiers.bold',
defaultMessage: 'Bold',
},
isValidEventKey: (event) => event.key === 'b',
checkIsActive: (editor) => Boolean(Editor.marks(editor)?.bold),
handleToggle: (editor) => {
if (Editor.marks(editor)?.bold) {
Editor.removeMark(editor, 'bold');
} else {
Editor.addMark(editor, 'bold', true);
}
},
},
};
```
@@ -0,0 +1,88 @@
---
title: Live preview
description: Frontend-agnostic preview with visual editing
tags:
- content-manager
---
The live preview feature lets users see their content rendered on their frontend while editing. It includes visual editing that identifies and highlights editable fields.
## Why not an SDK
Visual editing requires running some of our code on the user's frontend to detect fields and draw highlights. The obvious approach would be an SDK package users install in their project. We intentionally avoided this.
An SDK would require ongoing maintenance and create version mismatch risks between the SDK and Strapi. It would also tie us to specific frameworks or require multiple framework-specific packages.
Instead, the preview script is defined inside Strapi and sent to the frontend via `postMessage`. The frontend just needs a small snippet to receive and execute it. This keeps the script always in sync with the CMS version, works with any framework, and requires no package installation.
## How the script works
### Self-contained constraint
The preview script (`packages/core/content-manager/admin/src/preview/utils/previewScript.ts`) is stringified before being sent to the iframe:
```ts
const script = `(${previewScript.toString()})(${JSON.stringify(config)})`;
```
Because of this, it **cannot import dependencies or reference external variables**. All logic must be self-contained. The only external code (`@vercel/stega` for decoding) is loaded dynamically from a CDN at runtime.
This is why the file has an unusual structure with many functions defined inline.
### Field identification with stega
We use [stega encoding](https://github.com/vercel/stega) to identify which Strapi field each piece of text comes from. Stega embeds invisible metadata into text content using Unicode zero-width characters that are imperceptible to users but can be decoded programmatically.
1. The Document Service encodes field metadata into text values (invisible to users)
2. The frontend renders the content normally
3. The preview script decodes the metadata and attaches `data-strapi-source` attributes to DOM elements
4. Highlights are drawn over elements with source attributes
The metadata uses URL search params format, because it makes it easy to encode and decode multiple pieces of information into a single string: `path=title&type=string&documentId=abc123&locale=en&model=api::page.page`
### Stega limitations
Stega can only encode strings. This means:
- **Blocks fields don't support visual editing** — their content is a JSON object, not a string. Supporting them would require a custom implementation.
- **Numbers and booleans aren't encoded** — we can't modify their type in the response.
- **Fields inside components and dynamic zones work** — we encode individual string fields within them, not the parent object. The path includes indices (e.g., `components.2.title`) to identify the exact field.
- **Media fields work partially** — the string properties inside media objects (like `url`, `name`, `alternativeText`) get encoded when traversed.
### Communication protocol
The admin panel and preview iframe communicate via `postMessage`.
```mermaid
sequenceDiagram
participant Admin
participant Iframe as Preview Iframe
Note over Admin,Iframe: Initialization (public events)
Iframe->>Admin: previewReady
Admin->>Iframe: strapiScript
Note over Admin,Iframe: User edits in admin panel (internal events)
Admin->>Iframe: strapiFieldFocus
Admin->>Iframe: strapiFieldChange
Admin->>Iframe: strapiFieldBlur
Note over Admin,Iframe: User clicks in preview (internal events)
Iframe->>Admin: strapiFieldSingleClickHint
Iframe->>Admin: strapiFieldFocusIntent (double-click)
Note over Admin,Iframe: Content saved (public event)
Admin->>Iframe: strapiUpdate
```
Public events (`previewReady`, `strapiScript`, `strapiUpdate`) are documented to users—changing them is a breaking change.
Internal events (for field focus/blur/change synchronization) are defined in `packages/core/content-manager/admin/src/preview/utils/constants.ts` and can be changed freely since we control both ends.
### Frontend configuration
Users can configure the preview behavior from their frontend via `window` globals, without modifying Strapi:
- `window.STRAPI_DISABLE_STEGA_DECODING` - disable field detection entirely. When true, users need to write the `data-strapi-source` attribute manually for fields to be editable
- `window.STRAPI_HIGHLIGHT_HOVER_COLOR` - customize hover highlight color
- `window.STRAPI_HIGHLIGHT_ACTIVE_COLOR` - customize active highlight color
@@ -9,7 +9,7 @@ tags:
## Summary
Review workflows are disabled for all content-types by default and have to be enabled for each of them individually. More about how to
[enable review-workflows for a content-type](/docs/settings/review-workflows#edit--create-view). The feature itself is visible in two places
enable review-workflows for a content-type. The feature itself is visible in two places
of the content-manager: list and edit view.
### Nullish stages
@@ -40,7 +40,7 @@ add an additional check if the feature toggle returned in `http://localhost:1337
### Edit view
If the feature is enabled on the current content-type, the selected stage will show up in the information sidebar next to the edit view. Users
can select any other stage of the current workflow provided they have the necessary permissions to change the current stage see [EE Review Workflows](../admin/01-ee/01-review-workflows.md).
can select any other stage of the current workflow provided they have the necessary permissions to change the current stage see [EE Review Workflows](/docs/core/admin/ee/review-workflows).
Stage assignments are decoupled from entities, meaning updating entity attributes won't set the selected stage at the same time. Instead the stage select
component will trigger an atomic update on the admin API to assign/ update a stage to the current entity.
@@ -23,7 +23,9 @@ The following examples assume that you have already set up the `DndProvider` wit
that you are somewhat familiar with `@strapi/design-system` components.
:::
### Basic usage - Move items on hovering over drop zone
### Basic usage
Move items on hovering over drop zone
Below is a basic example usage where we move items immediately and we're not interested in rendering custom previews in the DragLayer.
However, we do replace the current item with a placeholder.
@@ -7,14 +7,14 @@ tags:
## Summary
There are two pages, ReleasesPage and ReleaseDetailsPage. To access these pages a user will need a valid Strapi license with the feature enabled and at lease `plugin::content-releases.read` permissions.
There are two pages, ReleasesPage and ReleaseDetailsPage. To access these pages a user will need a valid Strapi license with the feature enabled and at least `plugin::content-releases.read` permissions.
Redux toolkit is used to manage content releases data (data retrieval, release creation and editing, and fetching release actions). `Formik` is used to create/edit a release and all input components are controlled components.
### License limits
Most licenses have feature-based usage limits configured through Chargebee. These limits are exposed to the frontend through [`useLicenseLimits`](/docs/core/admin/ee/hooks/use-license-limits).
If the license doesn't specify the number of maximum pending releases an hard-coded is used: max. 3 pending releases.
If the license doesn't specify the number of maximum pending releases a hard-coded default is used: max. 3 pending releases.
### Endpoints
@@ -183,9 +183,9 @@ Once all stages have been completed, the transfer waits for all providers to clo
## Setting up the transfer engine
A transfer engine object is created by using `createTransferEngine`, which accepts a [source provider](./02-providers/01-source-providers.md), a [destination provider](./02-providers/02-destination-providers.md), and an options object.
A transfer engine object is created by using `createTransferEngine`, which accepts a [source provider](../02-providers/01-source-providers.md), a [destination provider](../02-providers/02-destination-providers.md), and an options object.
Note: By default, a transfer engine will transfer ALL data, including admin data, api tokens, etc. Transform filters must be used if you wish to exclude, as seen in the example below. An array called `DEFAULT_IGNORED_CONTENT_TYPES` is available from @strapi/data-transfer containing the uids that are excluded by default from the import, export, and transfer commands. If you intend to transfer admin data, be aware that this behavior will likely change in the future to automatically exclude the entire `admin::` uid namespace and will instead require them to be explicitly included.
Note: By default, a transfer engine will transfer ALL data, including admin data, api tokens, etc. Transform filters must be used if you wish to exclude, as seen in the example below. A helper function called `isIgnoredContentType` is available from the CLI utils and is used by the import, export, and transfer commands to exclude content types that should not be transferred. All content types in the `admin::` namespace are automatically excluded by prefix, along with certain explicitly listed types (such as content releases). If you need to transfer admin data, you must implement your own engine transforms without this filter.
```typescript
const engine = createTransferEngine(source, destination, options);
@@ -209,19 +209,16 @@ const options = {
{
// exclude all relations to ignored content types
filter(link) {
return (
!DEFAULT_IGNORED_CONTENT_TYPES.includes(link.left.type) &&
!DEFAULT_IGNORED_CONTENT_TYPES.includes(link.right.type)
);
return !isIgnoredContentType(link.left.type) && !isIgnoredContentType(link.right.type);
},
},
// Note: map exists for links but is not recommended
],
entities: [
{
// exclude all ignored content types
// exclude all ignored content types (admin:: prefix and explicit list)
filter(entity) {
return !DEFAULT_IGNORED_CONTENT_TYPES.includes(entity.type);
return !isIgnoredContentType(entity.type);
},
},
{
@@ -444,9 +441,9 @@ const options = {
transforms: {
entities: [
{
// exclude all ignored admin content types
// exclude all ignored content types (admin:: prefix and explicit list)
filter(entity) {
return !DEFAULT_IGNORED_CONTENT_TYPES.includes(entity.type);
return !isIgnoredContentType(entity.type);
},
},
{
@@ -460,10 +457,7 @@ const options = {
{
// exclude all relations to ignored content types
filter(link) {
return (
!DEFAULT_IGNORED_CONTENT_TYPES.includes(link.left.type) &&
!DEFAULT_IGNORED_CONTENT_TYPES.includes(link.right.type)
);
return !isIgnoredContentType(link.left.type) && !isIgnoredContentType(link.right.type);
},
},
{
@@ -491,9 +485,9 @@ const options = {
transforms: {
entities: [
{
// exclude all ignored content types
// exclude all ignored content types (admin:: prefix and explicit list)
filter(entity) {
return !DEFAULT_IGNORED_CONTENT_TYPES.includes(entity.type);
return !isIgnoredContentType(entity.type);
},
},
{
@@ -1,6 +1,6 @@
---
title: Providers
slug: /upload
slug: /upload/providers
tags:
- upload
---
@@ -0,0 +1,104 @@
---
title: MIME type validation
slug: /upload/mime-validation
tags:
- upload
- security
---
# MIME type validation
This page documents how the Upload plugin validates file MIME types when upload security is configured (`plugin::upload.security` with `allowedTypes` and/or `deniedTypes`). All accept/deny decisions go through a single helper so the logic stays simple and consistent.
## Inputs
- **declared** — Client-provided Content-Type (may be generic or missing).
- **fileName** — Upload filename (used to derive extension).
- **file** — File object (path or buffer) for content detection.
- **config** — `{ allowedTypes?: string[], deniedTypes?: string[] }`.
## Derived values
- **extension** — Filename extension (e.g. `.jpg`).
- **expectedMimeFromExt** — MIME type for that extension (`mime-types.lookup`), or null if unknown.
- **detected** — Result of `detectMimeType(file)` (content-based, e.g. via `file-type`); undefined if detection fails or throws.
- **Generic declared** — Declared is empty or `application/octet-stream`.
## Helper: validateAllowBanLists(mimetype)
All allow/deny outcomes go through this helper. It is the only place that decides accept vs reject for a given type.
- If mimetype is in **deniedTypes** → return REJECT.
- If **allowedTypes** is undefined → return ACCEPT (with stored type = mimetype).
- If **allowedTypes** is empty array → return REJECT.
- If mimetype matches any entry in **allowedTypes** (exact or pattern, e.g. `image/*`) → return ACCEPT (with stored type = mimetype).
- Else → return REJECT.
**Rule:** Whenever we have a type to evaluate, we **return validateAllowBanLists(that type)**. No extra branching on accept/deny outside this helper.
## Helper predicates
- **declaredMatchesExtension** — Declared is specific, expectedMimeFromExt is present, and declared equals (or pattern-matches) expectedMimeFromExt.
- **detectedMatchesDeclared** — Detected is defined and equals (or pattern-matches) declared.
---
## Validation steps (in order)
Steps are evaluated in sequence. Once a step returns, later steps are not run.
**1. Reject if declared type is denied.**
If the client explicitly declared a type that is on the deny list, reject immediately. We do not use or trust that type for storage.
**2. Reject if extension's type is denied.**
If the filename extension maps to a known MIME and that MIME is on the deny list, reject. This blocks dangerous types by extension even when declaration or detection might differ.
**3. Run content detection.**
Set **detected** = `detectMimeType(file)`. If detection throws, treat **detected** as undefined. Detection is used later to confirm declared type, to block denied types, and as a fallback when declaration is generic or missing.
**4. Trusted declaration: declared matches extension and detection confirms.**
If **declaredMatchesExtension** and **detectedMatchesDeclared**, we have a consistent declaration and content that matches it. Return **validateAllowBanLists(declared)**. We do not reject here just because detected might also match a denied type (e.g. polyglot -- for example, if we ban html but allow PDF, it is possible that a valid PDF with a .pdf extension could be accidentally rejected because it is detectable as html); we store the declared type. We only allow when detection confirms the file is valid for that type, so we never "trust" declared + extension without detection agreeing. This is safe because even if someone uploaded, for example, a polyglot jpg/exe file, if it has a .jpg extension and stored mimetype of jpg, the only way it could potentially be dangerous is if a user explicitly downloads, renames, AND sets the executable flag on their operating system
**5. Reject if detected type is denied.**
If we have a detected type and it is on the deny list, reject. This applies when we did not already accept in step 4 (e.g. declared did not match extension or detection did not match declared). Blocking on detected here avoids storing content that is identified as a denied type when we are not in the "trusted declaration" path.
**5b. Reject if allow list is set and detected is not in it.**
If **allowedTypes** is set, **detected** is defined, and detected does not match any allowed type, reject. This prevents extension or declared type from overriding content-based detection (e.g. a JPEG sent as `.pdf` with declared `application/pdf` is rejected when only PDF is allowed). **Exception:** When detected is `application/zip` and the extension maps to a type that is in the allow list (e.g. Office formats like docx, xlsx), do not reject here; step 6 will use the extension type. This is because content detection often returns `application/zip` for Office files when the buffer is small.
**6. Use detected type when it is defined and matches extension or declared is generic.**
If **detected** is defined and (expectedMimeFromExt is missing, or detected matches extension, or declared is generic), we have a usable content-derived type. Return **validateAllowBanLists(detected)**. This is the main fallback when we do not have a trusted declaration (step 4). **Implementation note:** When detected is `application/zip` and the extension maps to a specific type in the allow list (e.g. docx), the implementation may return **validateAllowBanLists(expectedMimeFromExt)** so Office uploads are allowed.
**7. Use extension's type when present.**
If **expectedMimeFromExt** is present, use it. Return **validateAllowBanLists(expectedMimeFromExt)**. This covers cases where detection failed or was undefined and we still have a known extension.
**8. Use declared type as last resort.**
If **declared** is present (including generic), use it. Return **validateAllowBanLists(declared)**. For example, when allowedTypes is undefined, this allows the upload with the declared type (e.g. generic); when allowedTypes is set, only allowed declared types pass.
**9. Reject when no type can be chosen.**
If we reach this step, we have no declared type and did not accept in 4, 6, or 7. Return REJECT (e.g. "cannot verify file type").
---
## Pseudocode (reference)
```
1. If declared is specific and declared is in deniedTypes → return REJECT.
2. If expectedMimeFromExt is present and expectedMimeFromExt is in deniedTypes → return REJECT.
3. Set detected = detectMimeType(file); if it throws, detected is undefined.
4. If declaredMatchesExtension and detectedMatchesDeclared → return validateAllowBanLists(declared).
5. If detected is defined and detected is in deniedTypes → return REJECT.
5b. If allowedTypes is set and detected is defined and detected is not in allowedTypes → return REJECT,
except when detected is application/zip and expectedMimeFromExt is in allowedTypes (then continue).
6. If detected is defined and (expectedMimeFromExt is missing or detected matches extension or declared is generic) → return validateAllowBanLists(detected); when detected is application/zip and extension type is in allow list, implementation may use expectedMimeFromExt.
7. If expectedMimeFromExt is present → return validateAllowBanLists(expectedMimeFromExt).
8. If declared is present → return validateAllowBanLists(declared).
9. return REJECT.
```
---
## Notes
- **Deny list:** Declared and extension are checked first (steps 12). Detected is only used to reject when we did not accept via trusted declaration (step 5), so e.g. "allow PDF, deny HTML" does not de-facto block PDF when the file is a PDF/HTML polyglot and we correctly trust declared + extension + detection for PDF.
- **Trusted declaration:** We only allow "declared + extension" when detection confirms the same type (step 4). We never store a type without either detection confirming it or using it via steps 68.
- **Single gate:** All accept/deny decisions go through **validateAllowBanLists** with one of declared, detected, or expectedMimeFromExt; step 9 is the only reject that does not call the helper (no type to pass).
+81 -24
View File
@@ -19,7 +19,28 @@ To run the e2e tests, you must first install the playwright browsers.
npx playwright install
```
Because we require a "fresh" instance to assert our e2e tests against this is included in the testing script so all you need to run is:
### Running Enterprise (EE) tests locally
To run the suite in **Enterprise** mode (or to let `yarn test:e2e` **auto-detect** EE when possible), put your license in the **e2e-specific env file**:
1. Copy `tests/e2e/.env.example` to **`tests/e2e/.env`** (path relative to the **monorepo root**). Do **not** put e2e-only variables in a `.env` at the monorepo root; the runner will not load it.
2. Set **`STRAPI_LICENSE`** to your license string (same role as the GitHub Actions secret `strapiLicense` on CI).
The unified runner (`tests/scripts/run-tests.js`) loads **`tests/e2e/.env`** with `dotenv` before starting Playwright; it does **not** automatically load `.env` from the repository root. Yarn/npm **`cross-env` does not read `.env` files** — only that loader does.
Then use:
| Goal | Command |
| ----------------------------------------------------------------------- | ------------------ |
| Auto (EE if `STRAPI_LICENSE` is set, else CE) | `yarn test:e2e` |
| Always CE (license stripped from the runner process so Strapi stays CE) | `yarn test:e2e:ce` |
| Always EE runner mode (exits if `STRAPI_LICENSE` is missing) | `yarn test:e2e:ee` |
Full precedence and CI behavior are documented in [CE vs EE and environment variables](#e2e-ce-ee-env) below.
### Default run
Because we require a "fresh" instance to assert our e2e tests against, that is included in the testing script. After `npx playwright install` (and optional EE `.env` above), run:
```shell
yarn test:e2e
@@ -27,7 +48,7 @@ yarn test:e2e
This will spawn by default a Strapi instance per testing domain (e.g. content-manager) in `test-apps` where the an individual `playwright.config` will start the instance and run tests against. It will automatically link the dependencies from the instance to the monorepo because `test-apps` are not considered part of the monorepo but we want to be using the most recent version of strapi (published or development) therefore meaning our most recent code changes can be tested against.
If you need to clean the test-apps folder because they are not working as expected, you can run `yarn test:e2e clean` which will clean said directory.
If you need to clean the test-apps folder because they are not working as expected, run `yarn test:e2e:clean` (that target invokes `run-e2e-tests.js clean`).
### Running specific tests
@@ -35,18 +56,18 @@ To run only one domain, meaning a top-level directory in e2e/tests such as "admi
```shell
yarn test:e2e --domains=admin
npm run test:e2e --domains=admin
```
To run a specific file, you can pass arguments and options to playwright using `--` between the test:e2e options and the playwright options, such as:
To pass file filters or Playwright-only flags (`--project`, `--grep`, `--reporter`, `--debug`, …), put them **after** a `--` that follows the runner options. (If you use **npm** instead of yarn, add one more `--` before those args: `npm run test:e2e -- --domains=admin -- login.spec.ts`.)
```shell
# to run just the login.spec.ts file in the admin domain
# run only login.spec.ts in the admin domain
yarn test:e2e --domains=admin -- login.spec.ts
npm run test:e2e --domains=admin -- login.spec.ts
```
Note that you must still include a domain, otherwise playwright will attempt to run every domain filtering by that filename, and any domains that do not contain that filename will fail with "no tests found"
You should still scope with `--domains` when filtering by file; otherwise every domain may be invoked and domains without that file can fail with "no tests found".
For **CI, scripts, or automation**, prefer `--reporter=line` after the inner `--` so the run exits without waiting on the HTML reporter.
### Running specific browsers
@@ -54,7 +75,6 @@ To run only a specific browser (to speed up test development, for example) you c
```shell
yarn test:e2e --domains=admin -- login.spec.ts --project=chromium
npm run test:e2e --domains=admin -- login.spec.ts --project=chromium
```
To debug your tests with a browser instance and the playwright debugger, you can pass the
@@ -67,37 +87,74 @@ yarn test:e2e --domains admin -- login.spec.ts --debug
### Concurrency / parallelization
By default, every domain is run with its own test app in parallel with the other domains. The tests within a domain are run in series, one at a time.
If you need an easier way to view the output, or have problems running multiple apps at once on your system, you can use the `-c` option
The runner uses `min(number of selected domains, concurrency)` **test apps** (`test-apps/e2e/test-app-*`), each bound to a port `8000 + index` within a batch. **`concurrency` defaults** to the number of domain folders under `tests/e2e/tests/` (not the count after `--domains`). Domains are chunked into batches of that size: domains in a batch run **in parallel**, batches run **one after another**. Spec files **inside** a domain are **serial** (`workers: 1`, `fullyParallel: false` in `playwright.base.config.js`).
```shell
# only run one domain at a time
# run at most one domain at a time (simplest logs; fully serial domains)
yarn test:e2e -c 1
# example: at most three domains at once, then the next three, etc.
yarn test:e2e -c 3
```
### Env Variables to Control Test Config
Some helpers have been added to allow you to modify the playwright configuration on your own system without touching the playwright config file used by the test runner.
| env var | Description | Default |
| ---------------------------- | -------------------------------------------- | ------------------ |
| PLAYWRIGHT_WEBSERVER_TIMEOUT | timeout for starting the Strapi server | 16000 (160s) |
| PLAYWRIGHT_ACTION_TIMEOUT | playwright action timeout (ie, click()) | 15000 (15s) |
| PLAYWRIGHT_EXPECT_TIMEOUT | playwright expect waitFor timeout | 10000 (10s) |
| PLAYWRIGHT_TIMEOUT | playwright timeout, for each individual test | 30000 (30s) |
| PLAYWRIGHT_OUTPUT_DIR | playwright output dir, such as trace files | '../test-results/' |
| PLAYWRIGHT_VIDEO | set 'true' to save videos on failed tests | false |
| env var | Description | Default |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------- |
| PLAYWRIGHT_WEBSERVER_TIMEOUT | Timeout (ms) for starting the Strapi server | 160000 (160s) |
| PLAYWRIGHT_ACTION_TIMEOUT | Playwright action timeout (e.g. `click()`) | 10000 (10s) |
| PLAYWRIGHT_EXPECT_TIMEOUT | `expect()` assertion timeout | 10000 (10s) |
| PLAYWRIGHT_TIMEOUT | Per-test timeout | 90000 (90s) |
| PLAYWRIGHT_OUTPUT_DIR | Base for traces/screenshots/videos; each domain uses a subfolder `<domain>-<port>` under that base. Also used as the JUnit output directory when set; when unset, JUnit defaults to `test-apps/junit-reports`. | `../test-results` (relative to each generated test app config) |
| PLAYWRIGHT_VIDEO | Set `true` to save videos on failed tests | false |
| PLAYWRIGHT_REUSE_EXISTING_SERVER | If `true` (local only; **ignored when `CI` is set**), Playwright may skip starting Strapi when the test URL already responds — faster if you keep a matching server up, **risky** if edition or license differs from this run. | `false` |
## Strapi Templates
The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) found at `e2e/app-template` in this folder we can store our premade content schemas & any customisations we may need such as other plugins / custom fields / endpoints etc.
The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) under `tests/app-template`. There we store premade content schemas and customisations such as other plugins, custom fields, or endpoints.
If you add anything to the template, be sure to add this information to [the docs](/testing/e2e/app-template).
If you add anything to the template, be sure to add this information to [the docs](/guides/e2e/app-template).
## Running tests with environment variables (needed to run EE tests)
## Running tests with environment variables (needed to run EE tests) {#e2e-ce-ee-env}
To set specific environment variables for your tests, a `.env` file can be created in the root of the e2e folder. This is useful if you need to run tests with a Strapi license or set future flags.
Create **`tests/e2e/.env`** next to the e2e tests (see **`tests/e2e/.env.example`**). **`tests/scripts/run-tests.js`** loads that file with `dotenv` when it exists, then **`tests/utils/e2e-edition.ts`** applies CE vs EE so the runner, Playwright, and Strapi agree. Do not rely on the **repository root** `.env` for e2e — it is not loaded unless you change the runner.
Optional: **`STRAPI_DISABLE_LICENSE_PING=true`** in the same file can match CI EE jobs when your license is offline-only (see `.github/actions/run-e2e-tests/script.sh`).
### `STRAPI_E2E_EDITION` (recommended mental model)
| Value | Meaning |
| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ce` | Community Edition: `STRAPI_DISABLE_EE=true` for Strapi; `STRAPI_LICENSE` is stripped from the runner env so children cannot boot as EE; EE-only specs are skipped. |
| `ee` | Enterprise: `STRAPI_DISABLE_EE` cleared; `STRAPI_LICENSE` must be present (and valid for Strapi to boot as EE). |
**Resolution order** (see `tests/utils/e2e-edition.ts`):
1. **`yarn test:e2e:ce`** → always CE (license removed from env for this process).
2. **`yarn test:e2e:ee`** → EE; **exits with an error** if `STRAPI_LICENSE` is missing.
3. **`STRAPI_E2E_EDITION`** set to **`ce`** or **`ee`** (after `tests/e2e/.env` is loaded, plus CI `script.sh` or your shell) — use that edition (`ee` without a license falls back to CE with a warning unless you used `test:e2e:ee`, which fails instead).
4. Else **auto**: EE if `STRAPI_LICENSE` is non-empty, else CE.
**CI:** `.github/workflows/tests.yml` defines two jobs:
- **`e2e_ce`** — no `STRAPI_LICENSE` secret; composite action runs with `runEE: false``script.sh` sets `STRAPI_E2E_EDITION=ce`.
- **`e2e_ee`** — `env STRAPI_LICENSE: ${{ secrets.strapiLicense }}` and `runEE: true``STRAPI_E2E_EDITION=ee` and `STRAPI_DISABLE_LICENSE_PING=true`.
CI runs **`script.sh`**, which exports **`STRAPI_E2E_EDITION`** (`ce` vs `ee`) before `yarn test:e2e`. **`e2e-edition.ts`** reads that (and **`STRAPI_LICENSE`**, **`STRAPI_DISABLE_LICENSE_PING`**, etc.) so the runner matches the job. Locally, the same applies: if you **`export STRAPI_E2E_EDITION=ce`** (or `ee`), that explicit value is respected when you run `yarn test:e2e`. If you want **pure auto** (license only: EE when `STRAPI_LICENSE` is set, else CE), **`unset STRAPI_E2E_EDITION`** so it is not set in your shell.
**Local — Yarn scripts** (`package.json`):
| Script | Behavior |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `yarn test:e2e` | Uses **`STRAPI_E2E_EDITION`** from the environment when set (`ce` / `ee`, per the table above); otherwise **auto**: EE if `STRAPI_LICENSE` is set in `tests/e2e/.env` (or exported), otherwise CE. |
| `yarn test:e2e:ce` | Always **CE**; license is stripped from env so Strapi cannot start as EE. |
| `yarn test:e2e:ee` | **Fails fast** without `STRAPI_LICENSE`; otherwise EE in the runner (Strapi still needs a valid license to boot as Enterprise). |
For convenience you can still use **`yarn test:e2e:ce`** / **`yarn test:e2e:ee`** instead of setting **`STRAPI_E2E_EDITION`** yourself.
Playwrights `reuseExistingServer` is **off by default** (see **`PLAYWRIGHT_REUSE_EXISTING_SERVER`** in the **Env Variables to Control Test Config** table) so a process already listening on the test port is not mistaken for this runs Strapi — edition and env match what `e2e-edition.ts` applied.
## Running tests with future flags
+3 -3
View File
@@ -9,7 +9,7 @@ tags:
## Overview
An app template has been created in `e2e/app-template` which provide some customizations and utilities to allow the tests to run. Note that if any changes are made to the app template, you will need to run `yarn test:e2e:clean` to update the test apps with the new template.
An app template lives under **`tests/app-template`** (shared with other test types). It provides customizations and utilities to allow the tests to run. If you change the template, run **`yarn test:e2e:clean`** and regenerate test apps so they pick up the new template.
Here you can read about what content schemas the test instance has & the API customisations we've built (incl. why we built them).
@@ -21,7 +21,7 @@ The app template should be realistic and structured in a way an actual user migh
To update the app template:
- Run `yarn test:e2e clean` to remove existing test apps
- Run `yarn test:e2e:clean` to remove existing test apps
- Run `yarn test:e2e -c=1 -- --ui` to generate a test app (don't run any tests)
- Follow the instructions to [import the existing data set](./02-data-transfer.md#importing-an-existing-data-packet)
- With the test app server running on 1337 you can now login to the app
@@ -30,7 +30,7 @@ To update the app template:
Once the app template is updated:
- Run `yarn test:e2e clean` to remove existing test apps
- Run `yarn test:e2e:clean` to remove existing test apps
- Run `yarn test:e2e -c=1 -- --ui` to generate a new test app using the updated template (don't run any tests)
- Follow the instructions to [import the existing data set](./02-data-transfer.md#importing-an-existing-data-packet)
- Follow the instructions to [export the updated data set](./02-data-transfer.md#exporting-an-updated-data-packet)
+14 -8
View File
@@ -13,11 +13,13 @@ This document explains how and why we use `@strapi/data-transfer` as means to re
### Why use Data Transfer?
We could use custom API endpoints of the application, and whilst this isn't a poor solution, it would _most likely_ require some code writing to set up the data for the schema entries. However, in `4.6.0` Strapi released the `DTS` feature (DTS Data Transfer System). This means any member of Strapi can export the data of their instance producing a `.tar` that we can then import programatically restoring the database to this point in time and ensuring a "pure" test environment.
We could use custom API endpoints of the application, and whilst this isn't a poor solution, it would _most likely_ require some code writing to set up the data for the schema entries. However, in `4.6.0` Strapi released the `DTS` feature (DTS Data Transfer System). This means any member of Strapi can export the data of their instance and we can import it programmatically to restore the database to this point in time and ensure a "pure" test environment.
End-to-end fixtures live under `tests/e2e/data` as **unpacked export directories** (same layout as inside a Strapi `.tar`: `metadata.json`, `schemas/`, `entities/`, `links/`, `configuration/`, `assets/`, …). That way JSON and JSONL changes show up as normal text diffs in Git. Binary media stays under `assets/uploads/` (ignored by Prettier).
### Limitations of Data Transfer
The main limitation with data transfer is we cannot version or review changes to the data. Making changes to the data set should be done with care since it is quite easy to export data with unknown changes to the database that could impact other tests.
Making changes to the data set should still be done with care: unknown changes to the database can impact other tests. Review JSONL and metadata diffs when updating fixtures.
## Updating data for tests
@@ -27,7 +29,7 @@ Each test should be isolated and not depend on another test. Data changes from o
Since the Strapi CLI will use `@strapi/data-transfer` directly it will by default not import or export admin users, API tokens, or any other features that have been included in its exclusion list.
For this reason, do NOT use the import or export command on the strapi test instance. A DTS engine has been created specifically for our tests cases. This allows us to redefine what should be included in the import or export for our tests. The scripts can be found in `tests/e2e/scripts/dts-import.ts` and `tests/e2e/scripts/dts-export.ts`.
For this reason, do NOT use the import or export command on the strapi test instance. A DTS engine has been created specifically for our tests cases. This allows us to redefine what should be included in the import or export for our tests. Helpers live in `tests/utils/dts-import.ts` (see also `tests/e2e/scripts/dts-export.ts` for exporting updated packets).
### Importing an existing data packet
@@ -35,12 +37,14 @@ When you need to update the data packet for a new test, you will first need a St
When running the `yarn test:e2e` command, test app instances are created in `test-apps/e2e/test-app-{n}`. You can use one of the these apps to update the data.
For **EE** features, use the same **`STRAPI_LICENSE`** as for e2e: set it in **`tests/e2e/.env`** (see [E2E setup → environment variables](./00-setup.md#e2e-ce-ee-env)) or **export** it in your shell for the manual `dts-import` / `dts-export` commands below (those scripts do not load `tests/e2e/.env` by themselves).
Navigate to one of the test-apps and run `yarn install && yarn develop`
Leave the development server running, and then run the following command to reset and seed the database with the current e2e data packet. The script expects the name of the data packet you want to import found in `tests/e2e/data`.
```shell
STRAPI_LICENSE=<license-with-ee-feature> npx ts-node <PATH_TO_SCRIPT>/dts-import.ts with-admin.tar
STRAPI_LICENSE=<license-with-ee-feature> npx ts-node <PATH_TO_SCRIPT>/dts-import.ts with-admin
```
This script will include admin users and all the content-types specified in `tests/e2e/constants.ts`
@@ -59,7 +63,9 @@ Now that you have a Strapi instance with the same data that each e2e starts with
Once you've created your new data from the test instance, you'll need to export it so it can be used in the end-to-end tests.
Be sure to include the content types you would like exported in the `ALLOWED_CONTENT_TYPES` array found in `tests/e2e/constants.js`.
Content types from the test app template are included automatically. If the data packet needs a new
system or plugin content type, add it to the explicit `ALLOWED_CONTENT_TYPES` entries in
`tests/e2e/constants.ts`.
The script accepts the backup destination filename as an argument. Run it from the directory of the strapi instance you created earlier based on the test-app template.
@@ -73,11 +79,11 @@ If you are exporting data for an EE feature you will need to run the script with
STRAPI_LICENSE=<license-with-ee-feature> npx ts-node <PATH_TO_SCRIPT>/dts-export.ts updated-data-packet
```
The script will create a file `updated-data-packet.tar`. You can copy this file over to `tests/e2e/data` so it can be used in the appropriate tests.
The script will create a file `updated-data-packet.tar`. Extract it into a directory (same layout as Strapis export) and add that directory under `tests/e2e/data` (for example `tests/e2e/data/updated-data-packet/`) so tests can import from the folder and you can review changes as text. Remove the `.tar` after extracting if you only keep the unpacked tree in Git.
### Importing the data packet in test scenarios
There's an abstraction for importing the data programmatically during tests named `resetDatabaseAndImportDataFromPath` found in `tests/e2e/utils/dts-import.ts`. Typically, you'll want to run this **before** each test:
There's an abstraction for importing the data programmatically during tests named `resetDatabaseAndImportDataFromPath` in `tests/utils/dts-import.ts`. Pass the name of a directory under `tests/e2e/data` (for example `with-admin`). You can still pass a path to a `.tar` file; the helper picks the file or directory source provider automatically. Typically, you'll want to run this **before** each test:
```ts
import { test } from '@playwright/test';
@@ -85,7 +91,7 @@ import { resetDatabaseAndImportDataFromPath } from './utils/dts-import';
test.describe('Strapi Application', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('backup.tar');
await resetDatabaseAndImportDataFromPath('with-admin');
await page.goto('/admin');
});
+11 -3
View File
@@ -12,7 +12,6 @@ const config = {
url: 'https://contributor.strapi.io',
baseUrl: '/',
onBrokenLinks: 'warn',
onBrokenMarkdownLinks: 'warn',
favicon: 'img/favicon.png',
organizationName: 'strapi',
projectName: 'strapi',
@@ -28,6 +27,9 @@ const config = {
},
markdown: {
mermaid: true,
hooks: {
onBrokenMarkdownLinks: 'warn',
},
},
plugins: [
() => ({
@@ -44,11 +46,16 @@ const config = {
}),
[
'docusaurus-plugin-typedoc',
// Plugin / TypeDoc options
{
entryPoints: ['../packages/core/strapi/src/admin.ts'],
tsconfig: '../packages/core/strapi/tsconfig.build.json',
entryDocument: null,
// `readme: 'none'` uses a single project page (no separate index). Together with
// `entryDocument: 'modules.md'` this avoids generating `index.md` (invalid MDX: bare `<br>` tags).
// Do not set `entryDocument: null` — it becomes an empty URL and TypeDoc tries to write
// to the output directory (EISDIR: "Could not write .../exports").
readme: 'none',
entryDocument: 'modules.md',
// Plugin output is under the docs content root (`site/docs/`); use `exports`, not `docs/exports`.
out: 'exports',
watch: process.env.TYPEDOC_WATCH,
},
@@ -69,6 +76,7 @@ const config = {
routeBasePath: '/',
sidebarPath: require.resolve('./sidebars.js'),
editUrl: 'https://github.com/strapi/strapi/tree/main/docs/',
remarkPlugins: [require('./remark-design-system-links')],
},
blog: false,
},
+15 -12
View File
@@ -31,21 +31,24 @@
"@babel/runtime-corejs3": "7.26.10"
},
"dependencies": {
"@cmfcmf/docusaurus-search-local": "1.1.0",
"@docusaurus/core": "3.8.1",
"@docusaurus/preset-classic": "3.8.1",
"@docusaurus/theme-mermaid": "3.8.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^1.1.1",
"prism-react-renderer": "^2.4.1",
"react": "18.3.0",
"react-dom": "18.3.0"
"@cmfcmf/docusaurus-search-local": "2.0.1",
"@docusaurus/core": "3.10.0",
"@docusaurus/preset-classic": "3.10.0",
"@docusaurus/theme-mermaid": "3.10.0",
"@mdx-js/react": "3.1.1",
"clsx": "2.1.1",
"prism-react-renderer": "2.4.1",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.8.1",
"@docusaurus/module-type-aliases": "3.10.0",
"@types/react": "18.3.2",
"@types/react-dom": "18.3.0",
"docusaurus-plugin-typedoc": "0.22.0",
"typedoc": "0.25.9",
"typedoc": "0.25.10",
"typedoc-plugin-markdown": "3.17.1",
"typescript": "5.3.3"
"typescript": "5.4.5",
"unist-util-visit": "5.0.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
const { visit } = require('unist-util-visit');
const DESIGN_SYSTEM = 'https://design-system.strapi.io';
/**
* TypeDoc pulls JSDoc from @strapi/design-system .d.ts files. Those comments link to Storybook
* using paths like `[Label](../?path=/docs/foundations-responsive--docs)` which resolve as site-relative
* URLs in Docusaurus and fail the link checker. Map them to the public Storybook host.
*/
function remarkDesignSystemLinks() {
return (tree) => {
visit(tree, 'link', (node) => {
if (typeof node.url !== 'string') {
return;
}
let m = node.url.match(/^\.\.\/\?path=(.+)$/);
if (m) {
node.url = `${DESIGN_SYSTEM}/?path=${m[1]}`;
return;
}
m = node.url.match(/^\.\.\?path=(.+)$/);
if (m) {
node.url = `${DESIGN_SYSTEM}/?path=${m[1]}`;
}
});
visit(tree, 'html', (node) => {
if (typeof node.value !== 'string' || !node.value.includes('?path=')) {
return;
}
node.value = node.value.replace(/href="\.\.\/\?path=([^"]+)"/g, (_, p) => {
return `href="${DESIGN_SYSTEM}/?path=${p}"`;
});
});
};
}
module.exports = remarkDesignSystemLinks;
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

File diff suppressed because one or more lines are too long
+1886 -3309
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
JWT_SECRET=replaceme
+126
View File
@@ -0,0 +1,126 @@
############################
# OS X
############################
.DS_Store
.AppleDouble
.LSOverride
Icon
.Spotlight-V100
.Trashes
._*
############################
# Linux
############################
*~
############################
# Windows
############################
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msm
*.msp
############################
# Packages
############################
*.7z
*.csv
*.dat
*.dmg
*.gz
*.iso
*.jar
*.rar
*.tar
*.zip
*.com
*.class
*.dll
*.exe
*.o
*.seed
*.so
*.swo
*.swp
*.swn
*.swm
*.out
*.pid
############################
# Logs and databases
############################
.tmp
*.log
*.sql
*.sqlite
*.sqlite3
############################
# Misc.
############################
*#
ssl
.idea
nbproject
public/uploads/*
!public/uploads/.gitkeep
############################
# Node.js
############################
lib-cov
lcov.info
pids
logs
results
node_modules
.node_history
############################
# Tests
############################
/coverage/
############################
# Strapi
############################
.env
license.txt
exports
*.cache
dist
build
documentation
.strapi-updater.json
types/generated
.strapi
############################
# Application specific
############################
snapshots/*
!snapshots/.gitkeep
/v4
+264
View File
@@ -0,0 +1,264 @@
# Complex Example Project
This project contains complex Strapi schemas for testing migrations between Strapi v4 and v5, plus a benchmark harness for measuring the performance of those migrations.
## Content Types
The project includes 8 content types covering the feature space v4→v5 migrations touch.
### Baseline feature combinations
- `basic` — no draft/publish, no i18n
- `basic-dp` — draft/publish
- `basic-dp-i18n` — draft/publish + i18n
- `relation` — relations + morphs + components + DZ
- `relation-dp` — + draft/publish
- `relation-dp-i18n` — + i18n
### Anti-pattern stress schemas
Intentionally unrealistic; each targets a specific migration code path.
- `hc-m2m-source` / `hc-m2m-target` — high-cardinality many-to-many. At `--multiplier 100` produces ~2K sources × ~2K targets × 10 fanout = 20K+ join rows, crossing the 1000-row chunk boundary in `copyRelationTableRows`.
## Supported databases
- **PostgreSQL 16** — via podman/docker container on `${POSTGRES_PORT:-5432}`
- **MySQL 8** — via container on `${MYSQL_PORT:-3306}`
- **MariaDB 11** — via container on `${MARIADB_PORT:-3307}`
- **SQLite** — file-based at `../complex-v4/.tmp/data.db` (override with `SQLITE_DATABASE_FILENAME`)
Container runtime is auto-detected in this order: `podman compose``podman-compose``docker compose``docker-compose`. Override with `STRAPI_BENCH_RUNTIME=podman|docker` on mixed-install hosts.
## Migration Testing Workflow
This project includes tools for testing migrations between Strapi v4 and v5 by creating an isolated v4 project and managing database snapshots. The complex example ships its own `docker-compose.dev.yml` so the database containers are independent of the monorepo root.
### Setup
1. **Create/Update the external v4 project:**
```bash
yarn setup:v4
```
This creates a Strapi v4 project outside the monorepo (default: a sibling directory named `complex-v4`). You can override the location via `V4_OUTSIDE_DIR`.
2. **Install v4 deps** (one-time):
```bash
cd <path-printed-by-setup>
yarn install
```
3. **Configure the v4 project** (only if you need custom DB creds):
```bash
cp .env.example .env
# Edit .env as needed
```
4. **Start the v4 project:**
```bash
yarn develop:postgres # or :mysql, :mariadb, :sqlite
```
### Database Management
The same per-command pattern applies to `postgres`, `mysql`, `mariadb`, and `sqlite`:
```bash
yarn db:start:<db> # start the DB container (no-op for sqlite)
yarn db:stop:<db> # stop the DB container (no-op for sqlite)
yarn db:snapshot:<db> <name> # snapshot current DB state
yarn db:restore:<db> <name> # restore DB from a named snapshot
yarn db:wipe:<db> # drop + recreate (clean slate)
yarn db:check:<db> # print table row counts (runs ANALYZE first for fresh stats)
```
Snapshots live in `snapshots/` and are gitignored:
- PostgreSQL: `snapshots/postgres-<name>.sql`
- MySQL: `snapshots/mysql-<name>.sql`
- MariaDB: `snapshots/mariadb-<name>.sql`
- SQLite: `snapshots/sqlite-<name>.db` (raw file copy; fast)
### Typical Migration Testing Workflow
1. **Setup v4 project** (if not already done):
```bash
yarn setup:v4
```
2. **Wipe the database** (ensures v4 format, no v5 schema):
```bash
yarn db:wipe:postgres
```
3. **Start v4 project** (in separate terminal, use the path printed by setup):
```bash
cd <path-printed-by-setup>
yarn develop:postgres
```
(v4 will automatically start its database if needed)
4. **Seed test data** in the v4 project:
```bash
yarn seed
```
5. **Create snapshot:**
```bash
cd examples/complex
yarn db:snapshot:postgres mybackup
```
6. **Stop v4 server** (Ctrl+C in v4 terminal)
7. **Start v5 server** with the same database:
```bash
yarn develop:postgres
```
Migrations will run automatically on startup.
8. **Validate migration** (no HTTP server needed):
```bash
yarn test:migration
```
9. **Test and fix bugs** as needed
10. **Restore snapshot** to reset database:
```bash
yarn db:restore:postgres mybackup
```
11. **Repeat from step 7** to test fixes
**Note:** The database container stays running even after stopping Strapi, so you can inspect the database or run multiple tests without restarting the container. The complex example uses its own Compose project name (`strapi_complex`) so it does not collide with other containers.
## Migration performance benchmark
For reviewing PRs that touch v4→v5 migration code, this project ships a benchmark harness that captures per-migration timings and produces baseline-vs-candidate reports across any combination of databases and multipliers.
### Quick start
```bash
# One-time setup
yarn setup:v4
cd ../../complex-v4 && yarn install && cd -
# Seed data (one snapshot per DB × multiplier, kept in snapshots/)
yarn bench:seed --db postgres --multiplier 100
# Capture baseline — on develop (or whatever you're comparing against)
yarn bench:run --db postgres --multiplier 100 --label baseline
# Capture candidate — git checkout or cherry-pick the PR, rebuild, then:
yarn workspace @strapi/database run build
yarn workspace @strapi/core run build
yarn bench:run --db postgres --multiplier 100 --label pr-xxxxx
# Generate matrix comparison report
yarn bench:compare --baseline baseline --candidate pr-xxxxx
```
Reports land in `results/`:
- `compare-<timestamp>.md` — clipboard-ready markdown, also echoed to stdout
- `compare-<timestamp>.html` — self-contained single-file HTML with inline SVG charts, sortable tables, and light/dark theme support via `prefers-color-scheme`
### Bench subcommands
- **`yarn bench:seed --db <db> --multiplier <n>`** — wipe + boot v4 + seed + snapshot. One-time per (db, multiplier). Runtime scales with multiplier; at `m=100` expect ~810 min per DB depending on hardware.
- **`yarn bench:run --db <db> --multiplier <n> --label <label>`** — restore snapshot + spawn Strapi v5 in migrate-then-exit mode + capture per-migration timings via a Node `--require` preload that subscribes to Umzug's native `migrating`/`migrated` events. Emits a result JSON to `results/<db>-<label>-<timestamp>.json`. Typically ~15s to several minutes depending on dataset size.
- **`yarn bench:compare --baseline <label> --candidate <label>`** — render a multiplier × database matrix plus per-cell per-migration breakdowns, to both markdown and self-contained HTML. Accepts partial data (missing cells render as `—`).
- **`yarn bench:suite --multiplier <n> [--dbs postgres,mysql,mariadb,sqlite]`** — chained `bench:run` across DBs for a given multiplier. Runs under whatever Strapi version is currently checked out; label via `--label`.
### Workflow for reviewing a migration-perf PR
1. On `develop`, seed once per (db, multiplier) you want data for.
2. Run baselines: `yarn bench:run --db <db> --multiplier <n> --label baseline`.
3. Cherry-pick the PR's commits (or `gh pr checkout`), rebuild `@strapi/database` and `@strapi/core`.
4. Run candidates with the same `(db, multiplier)` combinations, `--label pr-xxxxx`.
5. Reset cherry-pick + rebuild.
6. `yarn bench:compare --baseline baseline --candidate pr-xxxxx` — paste the markdown into a PR comment; attach the zipped HTML as an upload (GitHub comments don't render `.html` directly).
Snapshots are reused across `bench:run` invocations — you only re-seed when the schema itself changes.
### Benchmark-specific env vars
- `STRAPI_BENCH_HOOK_OUTPUT=<path>` — enables the timing preload (set automatically by `bench.js`, exposed for debugging). The hook self-disables when this isn't set, so the `--require` can safely live in other dev configs.
- `STRAPI_BENCH_HOOK_DEBUG=1` — verbose preload output (migration attach/record events to stderr).
- `STRAPI_BENCH_RUNTIME=podman|docker` — override the auto-detected container runtime.
- `SEED_CONCURRENCY=<n>` — how many entity-creation tasks run in parallel during `bench:seed` / `seed`. Default `5`, which stays under Strapi v4's default knex pool of `{min: 2, max: 10}`. Tune up only if you've also raised the pool max.
## Development Commands
### Simplified Database Commands
The easiest way to start Strapi with a specific database:
```bash
yarn develop:postgres # PostgreSQL container + Strapi dev server
yarn develop:mysql # MySQL container + Strapi dev server
yarn develop:mariadb # MariaDB container + Strapi dev server
yarn develop:sqlite # SQLite file (no container) + Strapi dev server
```
These commands:
- ✅ Automatically start the database container if it's not already running (no-op for sqlite)
- ✅ Configure Strapi to use the specified database (no manual config needed)
- ✅ Start the Strapi development server
- ✅ Keep the database container running when you press Ctrl+C (only Strapi stops)
**Note:** Default ports:
- PostgreSQL: `5432` (override with `POSTGRES_PORT`)
- MySQL: `3306` (override with `MYSQL_PORT`)
- MariaDB: `3307` (override with `MARIADB_PORT`)
Set the override env var if you have a local DB already bound to the default port:
```bash
POSTGRES_PORT=5433 yarn develop:postgres
```
### Standard Strapi Commands
- `yarn develop` — Start development server (defaults to PostgreSQL; requires a running DB)
- `yarn build` — Build for production
- `yarn start` — Start production server
- `yarn strapi` — Run Strapi CLI commands
## V5 Seeding (Large Dataset)
Use the v5 seeder in this project to generate a large dataset for homepage perf testing:
```bash
yarn seed:v5
```
You can scale the volume with a multiplier:
```bash
yarn seed:v5 -- --multiplier 20
```
Or:
```bash
SEED_MULTIPLIER=20 yarn seed:v5
```
+22
View File
@@ -0,0 +1,22 @@
const adminConfig = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET', 'example-token'),
},
apiToken: {
salt: env('API_TOKEN_SALT', 'example-salt'),
},
secrets: {
encryptionKey: env('ENCRYPTION_KEY', 'example-key'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT', 'example-salt'),
},
},
flags: {
nps: env.bool('FLAG_NPS', true),
promoteEE: env.bool('FLAG_PROMOTE_EE', true),
},
});
export default adminConfig;
@@ -1,4 +1,4 @@
module.exports = {
export default {
rest: {
defaultLimit: 25,
maxLimit: 100,
@@ -1,7 +1,5 @@
const path = require('path');
module.exports = ({ env }) => {
const client = env('DATABASE_CLIENT', 'sqlite');
export default ({ env }) => {
const client = env('DATABASE_CLIENT', 'postgres');
const connections = {
mysql: {
@@ -42,14 +40,12 @@ module.exports = ({ env }) => {
},
pool: { min: env.int('DATABASE_POOL_MIN', 2), max: env.int('DATABASE_POOL_MAX', 10) },
},
sqlite: {
connection: {
filename: path.join(__dirname, '..', env('DATABASE_FILENAME', '.tmp/data.db')),
},
useNullAsDefault: true,
},
};
if (!connections[client]) {
throw new Error(`Unsupported DATABASE_CLIENT: ${client}. Use "postgres" or "mysql".`);
}
return {
connection: {
client,
+1
View File
@@ -0,0 +1 @@
export default () => ({});
@@ -1,4 +1,4 @@
module.exports = [
export default [
'strapi::logger',
'strapi::errors',
'strapi::security',
+1
View File
@@ -0,0 +1 @@
export default () => ({});
+14
View File
@@ -0,0 +1,14 @@
const serverConfig = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS', ['toBeModified1', 'toBeModified2']),
},
transfer: {
remote: {
enabled: true,
},
},
});
export default serverConfig;
+44
View File
@@ -0,0 +1,44 @@
services:
postgres:
image: postgres:16
restart: always
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: strapi
POSTGRES_PASSWORD: strapi
POSTGRES_DB: strapi
ports:
- '${POSTGRES_PORT:-5432}:5432'
mysql:
image: mysql:8
restart: always
environment:
MYSQL_DATABASE: strapi
MYSQL_USER: strapi
MYSQL_PASSWORD: strapi
MYSQL_ROOT_HOST: '%'
MYSQL_ROOT_PASSWORD: strapi
volumes:
- mysqldata:/var/lib/mysql
ports:
- '${MYSQL_PORT:-3306}:3306'
mariadb:
image: mariadb:11
restart: always
environment:
MARIADB_DATABASE: strapi
MARIADB_USER: strapi
MARIADB_PASSWORD: strapi
MARIADB_ROOT_PASSWORD: strapi
volumes:
- mariadbdata:/var/lib/mysql
ports:
- '${MARIADB_PORT:-3307}:3306'
volumes:
pgdata:
mysqldata:
mariadbdata:
Binary file not shown.

After

Width:  |  Height:  |  Size: 497 B

+70
View File
@@ -0,0 +1,70 @@
{
"name": "complex",
"version": "0.0.0",
"private": true,
"description": "A Strapi application with complex schemas",
"scripts": {
"build": "strapi build",
"dev": "strapi develop",
"develop": "strapi develop",
"develop:postgres": "node scripts/develop-with-db.js postgres",
"develop:mysql": "node scripts/develop-with-db.js mysql",
"develop:mariadb": "node scripts/develop-with-db.js mariadb",
"develop:sqlite": "node scripts/develop-with-db.js sqlite",
"db:start:postgres": "node scripts/db-postgres.js start",
"db:stop:postgres": "node scripts/db-postgres.js stop",
"db:snapshot:postgres": "node scripts/db-postgres.js snapshot",
"db:restore:postgres": "node scripts/db-postgres.js restore",
"db:wipe:postgres": "node scripts/db-postgres.js wipe",
"db:check:postgres": "node scripts/db-postgres.js check",
"db:start:mysql": "node scripts/db-mysql.js start",
"db:stop:mysql": "node scripts/db-mysql.js stop",
"db:snapshot:mysql": "node scripts/db-mysql.js snapshot",
"db:restore:mysql": "node scripts/db-mysql.js restore",
"db:wipe:mysql": "node scripts/db-mysql.js wipe",
"db:check:mysql": "node scripts/db-mysql.js check",
"db:start:mariadb": "node scripts/db-mariadb.js start",
"db:stop:mariadb": "node scripts/db-mariadb.js stop",
"db:snapshot:mariadb": "node scripts/db-mariadb.js snapshot",
"db:restore:mariadb": "node scripts/db-mariadb.js restore",
"db:wipe:mariadb": "node scripts/db-mariadb.js wipe",
"db:check:mariadb": "node scripts/db-mariadb.js check",
"db:start:sqlite": "node scripts/db-sqlite.js start",
"db:stop:sqlite": "node scripts/db-sqlite.js stop",
"db:snapshot:sqlite": "node scripts/db-sqlite.js snapshot",
"db:restore:sqlite": "node scripts/db-sqlite.js restore",
"db:wipe:sqlite": "node scripts/db-sqlite.js wipe",
"db:check:sqlite": "node scripts/db-sqlite.js check",
"start": "strapi start",
"setup:v4": "node scripts/setup-v4-project.js",
"seed:v5": "node scripts/seed-v5.js",
"test:migration": "node scripts/validate-migration.js",
"bench:seed": "node scripts/bench.js seed",
"bench:run": "node scripts/bench.js run",
"bench:compare": "node scripts/bench-compare.js",
"bench:suite": "node scripts/bench.js suite"
},
"dependencies": {
"@strapi/plugin-users-permissions": "workspace:*",
"@strapi/strapi": "workspace:*",
"better-sqlite3": "12.8.0",
"mysql2": "3.20.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "6.30.3",
"styled-components": "6.1.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "^5"
},
"engines": {
"node": ">=20.0.0 <=24.x.x",
"npm": ">=6.0.0"
},
"strapi": {
"uuid": "complex"
}
}
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow:
+669
View File
@@ -0,0 +1,669 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Compare benchmark result sets across multipliers and databases.
*
* Usage:
* node bench-compare.js --baseline <label> --candidate <label>
* node bench-compare.js <baseline-label> <candidate-label> # legacy positional form
*
* Result files are loaded from `results/*.json` and indexed by:
* { label, multiplier, dbEngine }
* taken from fields inside each JSON (not the filename), so the same
* canonical baseline/candidate label can span any number of (multiplier, db)
* combinations. Missing cells are rendered as "—" rather than errors.
*
* Produces:
* - Markdown report: stdout + results/compare-<timestamp>.md
* - Self-contained HTML: results/compare-<timestamp>.html
*/
const fs = require('fs');
const path = require('path');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const RESULTS_DIR = path.join(COMPLEX_DIR, 'results');
// ─── argument parsing ────────────────────────────────────────────────────────
function parseArgs(argv) {
const flags = {};
const positional = [];
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a.startsWith('--')) {
const eq = a.indexOf('=');
if (eq !== -1) {
flags[a.slice(2, eq)] = a.slice(eq + 1);
} else {
const next = argv[i + 1];
if (next != null && !next.startsWith('--')) {
flags[a.slice(2)] = next;
i += 1;
} else {
flags[a.slice(2)] = true;
}
}
} else {
positional.push(a);
}
}
return { flags, positional };
}
const { flags, positional } = parseArgs(process.argv.slice(2));
const baselineLabel = flags.baseline ?? positional[0];
const candidateLabel = flags.candidate ?? positional[1];
if (!baselineLabel || !candidateLabel) {
console.error('Usage: node bench-compare.js --baseline <label> --candidate <label>');
console.error(' node bench-compare.js <baseline-label> <candidate-label>');
process.exit(1);
}
if (!fs.existsSync(RESULTS_DIR)) {
console.error(`No results/ directory found at ${RESULTS_DIR}`);
process.exit(1);
}
// ─── result loading ──────────────────────────────────────────────────────────
/**
* Strip a trailing `-m<N>` suffix from labels so older results (e.g.,
* "baseline-develop-m100") can be matched against canonical labels
* ("baseline-develop"). Leaves labels without the suffix untouched.
*/
function normalizeLabel(raw) {
if (typeof raw !== 'string') return raw;
return raw.replace(/-m\d+$/, '');
}
/**
* Load every result into a flat list, keeping only the most recent per
* (normalized-label, multiplier, dbEngine) triple.
*/
function loadResults() {
const files = fs
.readdirSync(RESULTS_DIR)
.filter((f) => f.endsWith('.json'))
.sort(); // lexicographic on ISO timestamps = chronological
const byKey = new Map();
for (const file of files) {
let data;
try {
data = JSON.parse(fs.readFileSync(path.join(RESULTS_DIR, file), 'utf8'));
} catch (err) {
console.error(`[bench-compare] skipping unparseable ${file}: ${err.message}`);
continue;
}
const db = data?.env?.dbEngine;
const multiplier = data?.config?.multiplier;
const label = normalizeLabel(data?.label);
if (!db || multiplier == null || !label) continue;
byKey.set(`${label}::${multiplier}::${db}`, { ...data, __file: file });
}
return byKey;
}
const results = loadResults();
/**
* Return the result for (label, multiplier, db) or undefined.
*/
function getResult(label, multiplier, db) {
return results.get(`${label}::${multiplier}::${db}`);
}
/**
* Collect every multiplier and dbEngine seen across baseline + candidate.
*/
function collectAxes() {
const multipliers = new Set();
const dbs = new Set();
for (const key of results.keys()) {
const [label, mult, db] = key.split('::');
if (label === baselineLabel || label === candidateLabel) {
multipliers.add(Number(mult));
dbs.add(db);
}
}
return {
multipliers: [...multipliers].sort((a, b) => a - b),
dbs: [...dbs].sort(),
};
}
const { multipliers, dbs } = collectAxes();
if (multipliers.length === 0) {
console.error(
`[bench-compare] no results found for labels "${baselineLabel}" or "${candidateLabel}". Have you run bench:run yet?`
);
console.error('Available label/multiplier/db combos:');
for (const key of [...results.keys()].sort()) console.error(` ${key}`);
process.exit(1);
}
// ─── formatting helpers ──────────────────────────────────────────────────────
const REGRESSION_THRESHOLD_PCT = 5;
function pctChange(baseline, candidate) {
if (baseline === 0 || baseline == null || candidate == null) return null;
return ((candidate - baseline) / baseline) * 100;
}
function fmtMs(ms) {
if (ms == null || Number.isNaN(ms)) return '—';
if (ms < 1000) return `${ms.toFixed(0)} ms`;
if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
return `${(ms / 60000).toFixed(2)} min`;
}
function fmtDelta(ms) {
if (ms == null || Number.isNaN(ms)) return '—';
const sign = ms > 0 ? '+' : ms < 0 ? '' : '';
return `${sign}${fmtMs(Math.abs(ms))}`;
}
function fmtPct(pct) {
if (pct == null || Number.isNaN(pct)) return '—';
const sign = pct > 0 ? '+' : pct < 0 ? '' : '';
return `${sign}${Math.abs(pct).toFixed(1)}%`;
}
function fmtSpeedup(baseline, candidate) {
if (!baseline || !candidate) return '—';
const ratio = baseline / candidate;
if (ratio >= 1) return `${ratio.toFixed(2)}×`;
return `${(1 / ratio).toFixed(2)}× slower`;
}
function pctClass(pct) {
if (pct == null) return '';
if (pct > REGRESSION_THRESHOLD_PCT) return 'regression';
if (pct < -REGRESSION_THRESHOLD_PCT) return 'improvement';
return '';
}
// ─── per-cell detail ─────────────────────────────────────────────────────────
function pairedMigrationRows(baseline, candidate) {
const names = new Set();
(baseline?.migrations ?? []).forEach((m) => names.add(m.name));
(candidate?.migrations ?? []).forEach((m) => names.add(m.name));
return [...names].map((name) => ({
name,
baseline: baseline?.migrations?.find((m) => m.name === name)?.durationMs ?? null,
candidate: candidate?.migrations?.find((m) => m.name === name)?.durationMs ?? null,
}));
}
// ─── representative pick (for setup block) ───────────────────────────────────
function pickRepresentative(label) {
for (const mult of multipliers) {
for (const db of dbs) {
const r = getResult(label, mult, db);
if (r) return r;
}
}
return null;
}
const baselineRep = pickRepresentative(baselineLabel);
const candidateRep = pickRepresentative(candidateLabel);
function renderSetupInline(result) {
if (!result) return '_(no data)_';
const src = result.strapiSource || 'unknown';
const ver = result.strapiVersion || '?';
const sha = result.strapiGitSha ? ` @ ${result.strapiGitSha.slice(0, 10)}` : '';
const branch = result.strapiGitBranch ? ` (branch \`${result.strapiGitBranch}\`)` : '';
return `Strapi ${ver}${sha}${branch} [source: ${src}]`;
}
function renderEnvInline(result) {
const e = result?.env;
if (!e) return '_(no env info)_';
return `Node ${e.nodeVersion} · ${e.platform}/${e.arch} · ${e.cpuModel} (${e.cpuCount} cores) · ${Math.round(
e.totalMemMB / 1024
)} GB`;
}
// ─── markdown rendering ──────────────────────────────────────────────────────
function renderMarkdown() {
let out = '';
out += `## Migration benchmark: ${baselineLabel} vs ${candidateLabel}\n\n`;
// Test setup
out += `### Test setup\n\n`;
out += `- **Baseline (${baselineLabel}):** ${renderSetupInline(baselineRep)}\n`;
out += `- **Candidate (${candidateLabel}):** ${renderSetupInline(candidateRep)}\n`;
if (baselineRep) {
out += `- **Host env:** ${renderEnvInline(baselineRep)}\n`;
}
const cfg = baselineRep?.config;
if (cfg) {
out += `- **Config (representative):** seed=${cfg.seedMode}, hook=${cfg.hookMode}\n`;
}
out += `\n`;
// Cross-multiplier × cross-DB summary table
out += `### Speedup matrix (total migration time)\n\n`;
out += `Rows: multipliers · Columns: databases. Each cell shows ${'`baseline → candidate (% change)`'}.\n\n`;
const header = ['multiplier', ...dbs];
out += `| ${header.join(' | ')} |\n`;
out += `| ${header.map((_, i) => (i === 0 ? ':---' : '---:')).join(' | ')} |\n`;
for (const mult of multipliers) {
const cells = [`**m=${mult}**`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) {
cells.push('—');
continue;
}
const bt = b.totalDurationMs;
const ct = c.totalDurationMs;
const pct = pctChange(bt, ct);
cells.push(`${fmtMs(bt)}${fmtMs(ct)} (${fmtPct(pct)})`);
}
out += `| ${cells.join(' | ')} |\n`;
}
out += `\n`;
// Data-availability matrix (baseline/candidate presence)
out += `### Data points captured\n\n`;
const availHeader = ['multiplier', ...dbs];
out += `| ${availHeader.join(' | ')} |\n`;
out += `| ${availHeader.map((_, i) => (i === 0 ? ':---' : ':---:')).join(' | ')} |\n`;
for (const mult of multipliers) {
const cells = [`m=${mult}`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
const hasB = b ? '✓' : ' ';
const hasC = c ? '✓' : ' ';
cells.push(`base:${hasB} pr:${hasC}`);
}
out += `| ${cells.join(' | ')} |\n`;
}
out += `\n`;
// Per-cell detailed breakdowns
out += `### Per-migration detail\n\n`;
for (const mult of multipliers) {
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) continue;
out += `#### ${db} @ m=${mult}\n\n`;
const rowCountTotal = Object.values(b.rowCount || {}).reduce(
(a, n) => (typeof n === 'number' ? a + n : a),
0
);
out += `Row count (baseline, after migration): ~${rowCountTotal.toLocaleString()}\n\n`;
const rows = pairedMigrationRows(b, c);
out += `| Migration | Baseline | Candidate | Δ | % change |\n`;
out += `| :--- | ---: | ---: | ---: | ---: |\n`;
const regressionLines = [];
for (const row of rows) {
const delta =
row.candidate != null && row.baseline != null ? row.candidate - row.baseline : null;
const pct = pctChange(row.baseline, row.candidate);
out += `| ${row.name} | ${fmtMs(row.baseline)} | ${fmtMs(row.candidate)} | ${fmtDelta(delta)} | ${fmtPct(pct)} |\n`;
if (pct != null && pct > REGRESSION_THRESHOLD_PCT) {
regressionLines.push(` - \`${row.name}\` regressed by ${fmtPct(pct)}`);
}
}
const totalDelta = c.totalDurationMs - b.totalDurationMs;
const totalPct = pctChange(b.totalDurationMs, c.totalDurationMs);
out += `| **Total** | **${fmtMs(b.totalDurationMs)}** | **${fmtMs(c.totalDurationMs)}** | **${fmtDelta(totalDelta)}** | **${fmtPct(totalPct)}** |\n`;
out += `\n**Speedup:** ${fmtSpeedup(b.totalDurationMs, c.totalDurationMs)}\n`;
if (regressionLines.length) {
out += `\n⚠️ Per-migration regressions (>${REGRESSION_THRESHOLD_PCT}%):\n${regressionLines.join('\n')}\n`;
}
out += `\n`;
}
}
return out;
}
// ─── HTML rendering ──────────────────────────────────────────────────────────
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderSvgBarChart(rows) {
if (rows.length === 0) return '';
const barHeight = 16;
const barGap = 2;
const rowGap = 10;
const leftPad = 8;
const rightPad = 80;
const charWidth = 7.2;
const longestName = rows.reduce((a, r) => Math.max(a, r.name.length), 0);
const labelColWidth = Math.round(Math.min(Math.max(longestName * charWidth + 16, 180), 420));
const seriesCount = 2; // baseline + candidate
const rowHeight = (barHeight + barGap) * seriesCount + rowGap;
const chartHeight = rows.length * rowHeight + rowGap;
const barAreaWidth = 320;
const width = leftPad + labelColWidth + barAreaWidth + rightPad;
const maxDuration = Math.max(...rows.flatMap((r) => [r.baseline ?? 0, r.candidate ?? 0]), 1);
const scale = (v) => (v == null ? 0 : (v / maxDuration) * barAreaWidth);
const textStyle = 'font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace';
const textStyleMuted = `${textStyle}; fill: var(--muted)`;
const textStyleFg = `${textStyle}; fill: var(--text)`;
let svg = `<svg viewBox="0 0 ${width} ${chartHeight}" width="100%" role="img" aria-label="Per-migration duration bars" style="max-width: ${width}px">`;
rows.forEach((row, rowIdx) => {
const yBase = rowIdx * rowHeight + rowGap / 2;
const series = [
{ value: row.baseline, color: 'var(--baseline-color)', title: 'baseline' },
{ value: row.candidate, color: 'var(--candidate-1-color)', title: 'candidate' },
];
const nameYCenter = yBase + (seriesCount * (barHeight + barGap)) / 2 - barGap / 2;
svg += `<text x="${leftPad}" y="${nameYCenter}" dominant-baseline="middle" style="${textStyleFg}">${escapeHtml(row.name)}</text>`;
series.forEach((s, i) => {
const y = yBase + i * (barHeight + barGap);
const textY = y + barHeight / 2;
const barX = leftPad + labelColWidth;
if (s.value != null) {
const w = scale(s.value);
const title = `${s.title}: ${fmtMs(s.value)}`;
svg += `<rect x="${barX}" y="${y}" width="${w.toFixed(1)}" height="${barHeight}" fill="${s.color}" rx="2"><title>${escapeHtml(title)}</title></rect>`;
svg += `<text x="${(barX + w + 4).toFixed(1)}" y="${textY}" dominant-baseline="middle" style="${textStyleMuted}">${fmtMs(s.value)}</text>`;
} else {
svg += `<text x="${barX}" y="${textY}" dominant-baseline="middle" style="${textStyleMuted}">—</text>`;
}
});
});
svg += `</svg>`;
return svg;
}
function renderCellDetailHtml(mult, db, baseline, candidate) {
const rows = pairedMigrationRows(baseline, candidate);
const rowCountTotal = Object.values(baseline.rowCount || {}).reduce(
(a, n) => (typeof n === 'number' ? a + n : a),
0
);
const tableRows = rows
.map((row) => {
const delta =
row.candidate != null && row.baseline != null ? row.candidate - row.baseline : null;
const pct = pctChange(row.baseline, row.candidate);
return `<tr>
<td>${escapeHtml(row.name)}</td>
<td class="num">${fmtMs(row.baseline)}</td>
<td class="num">${fmtMs(row.candidate)}</td>
<td class="num">${fmtDelta(delta)}</td>
<td class="num ${pctClass(pct)}">${fmtPct(pct)}</td>
</tr>`;
})
.join('');
const totalDelta = candidate.totalDurationMs - baseline.totalDurationMs;
const totalPct = pctChange(baseline.totalDurationMs, candidate.totalDurationMs);
const svg = renderSvgBarChart(rows);
return `
<details class="cell-detail">
<summary><strong>${escapeHtml(db)} @ m=${mult}</strong> ${fmtMs(baseline.totalDurationMs)} ${fmtMs(candidate.totalDurationMs)} (<span class="${pctClass(totalPct)}">${fmtPct(totalPct)}</span>), speedup ${fmtSpeedup(baseline.totalDurationMs, candidate.totalDurationMs)} · ~${rowCountTotal.toLocaleString()} rows</summary>
<div class="cell-body">
<div class="chart-wrap">${svg}</div>
<table class="sortable">
<thead>
<tr>
<th>Migration</th>
<th class="num">Baseline</th>
<th class="num">Candidate</th>
<th class="num">Δ</th>
<th class="num">% change</th>
</tr>
</thead>
<tbody>
${tableRows}
<tr class="total-row">
<th>Total</th>
<th class="num">${fmtMs(baseline.totalDurationMs)}</th>
<th class="num">${fmtMs(candidate.totalDurationMs)}</th>
<th class="num">${fmtDelta(totalDelta)}</th>
<th class="num ${pctClass(totalPct)}">${fmtPct(totalPct)}</th>
</tr>
</tbody>
</table>
</div>
</details>
`;
}
function renderHtml() {
// Summary matrix cells
const matrixRows = multipliers
.map((mult) => {
const cells = [`<th>m=${mult}</th>`];
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (!b || !c) {
cells.push('<td class="num muted">—</td>');
continue;
}
const pct = pctChange(b.totalDurationMs, c.totalDurationMs);
cells.push(
`<td class="num ${pctClass(pct)}" title="${escapeHtml(`${fmtMs(b.totalDurationMs)}${fmtMs(c.totalDurationMs)}`)}">${fmtMs(b.totalDurationMs)}${fmtMs(c.totalDurationMs)}<br><span class="delta">${fmtPct(pct)} · ${fmtSpeedup(b.totalDurationMs, c.totalDurationMs)}</span></td>`
);
}
return `<tr>${cells.join('')}</tr>`;
})
.join('');
const headerCells = ['<th>multiplier</th>', ...dbs.map((db) => `<th>${escapeHtml(db)}</th>`)];
// Per-cell details
const details = [];
for (const mult of multipliers) {
for (const db of dbs) {
const b = getResult(baselineLabel, mult, db);
const c = getResult(candidateLabel, mult, db);
if (b && c) details.push(renderCellDetailHtml(mult, db, b, c));
}
}
// Verdict (use postgres at the largest multiplier if available)
const verdictDb = dbs.includes('postgres') ? 'postgres' : dbs[0];
const verdictMult = multipliers[multipliers.length - 1];
const vB = getResult(baselineLabel, verdictMult, verdictDb);
const vC = getResult(candidateLabel, verdictMult, verdictDb);
let verdict = 'No data to summarize';
if (vB && vC) {
const ratio = vB.totalDurationMs / vC.totalDurationMs;
if (ratio > 1.05) {
verdict = `${ratio.toFixed(2)}× faster on ${verdictDb} @ m=${verdictMult}`;
} else if (ratio < 0.95) {
verdict = `${(1 / ratio).toFixed(2)}× slower on ${verdictDb} @ m=${verdictMult}`;
} else {
verdict = `≈ no change on ${verdictDb} @ m=${verdictMult}`;
}
}
const cssVars = `
--baseline-color: #888;
--candidate-1-color: #2563eb;
--regression-bg: #fee2e2;
--regression-fg: #991b1b;
--improvement-bg: #dcfce7;
--improvement-fg: #166534;
`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Migration benchmark: ${escapeHtml(baselineLabel)} vs ${escapeHtml(candidateLabel)}</title>
<style>
:root {
--bg: #ffffff;
--text: #111827;
--muted: #6b7280;
--border: #e5e7eb;
--card-bg: #f9fafb;
--mono-font: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
${cssVars}
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0a0a0a;
--text: #e5e7eb;
--muted: #9ca3af;
--border: #374151;
--card-bg: #111827;
--baseline-color: #6b7280;
--candidate-1-color: #60a5fa;
--regression-bg: #450a0a;
--regression-fg: #fca5a5;
--improvement-bg: #052e16;
--improvement-fg: #86efac;
}
}
html, body { background: var(--bg); color: var(--text); margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
h1 { margin-top: 0; }
h2 { border-bottom: 1px solid var(--border); padding-bottom: 0.25rem; margin-top: 2rem; }
.verdict { font-size: 1.2rem; padding: 0.5rem 0.75rem; background: var(--card-bg); border-radius: 6px; border: 1px solid var(--border); }
.setup dt { font-weight: 600; color: var(--muted); margin-top: 0.5rem; }
.setup dd { margin-left: 0; }
table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
th, td { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
th { user-select: none; }
table.sortable th { cursor: pointer; }
table.sortable th:hover { background: rgba(127,127,127,0.1); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--mono-font); white-space: nowrap; }
td.num .delta { display: block; font-size: 0.85em; color: var(--muted); }
tr.total-row th, tr.total-row td { border-top: 2px solid var(--border); font-weight: 600; }
td.regression, th.regression { background: var(--regression-bg); color: var(--regression-fg); }
td.improvement, th.improvement { background: var(--improvement-bg); color: var(--improvement-fg); }
td.muted { color: var(--muted); }
.cell-detail { margin: 0.5rem 0; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem 0.75rem; }
.cell-detail summary { cursor: pointer; padding: 0.25rem 0; }
.cell-detail .cell-body { padding-top: 0.5rem; }
.chart-wrap { overflow-x: auto; padding: 0.5rem 0; }
footer { margin-top: 3rem; color: var(--muted); font-size: 0.85rem; border-top: 1px solid var(--border); padding-top: 1rem; }
</style>
</head>
<body>
<h1>Migration benchmark: ${escapeHtml(baselineLabel)} vs ${escapeHtml(candidateLabel)}</h1>
<p class="verdict">${escapeHtml(verdict)}</p>
<section class="setup">
<h2>Test setup</h2>
<dl>
<dt>Baseline (${escapeHtml(baselineLabel)})</dt>
<dd>${escapeHtml(renderSetupInline(baselineRep))}</dd>
<dt>Candidate (${escapeHtml(candidateLabel)})</dt>
<dd>${escapeHtml(renderSetupInline(candidateRep))}</dd>
${baselineRep?.env ? `<dt>Host env</dt><dd>${escapeHtml(renderEnvInline(baselineRep))}</dd>` : ''}
${baselineRep?.config ? `<dt>Config (representative)</dt><dd>seed=${escapeHtml(baselineRep.config.seedMode || '?')}, hook=${escapeHtml(baselineRep.config.hookMode || '?')}</dd>` : ''}
</dl>
</section>
<section>
<h2>Speedup matrix</h2>
<p>Rows: multipliers · Columns: databases. Each cell shows <code>baseline candidate</code> with Δ% and speedup. Empty cells mean no data for that combination yet.</p>
<table>
<thead><tr>${headerCells.join('')}</tr></thead>
<tbody>${matrixRows}</tbody>
</table>
</section>
<section>
<h2>Per-migration detail</h2>
<p>Click a row to expand per-migration breakdown for that (database, multiplier) pair.</p>
${details.join('')}
</section>
<footer>
Generated ${new Date().toISOString()} · bench-compare.js · Strapi migration benchmark harness
</footer>
<script>
document.querySelectorAll('table.sortable').forEach((table) => {
const headers = table.querySelectorAll('th');
headers.forEach((th, colIdx) => {
th.addEventListener('click', () => {
const tbody = table.tBodies[0];
const rows = Array.from(tbody.querySelectorAll('tr:not(.total-row)'));
const dir = th.dataset.sortDir === 'asc' ? 'desc' : 'asc';
headers.forEach((h) => delete h.dataset.sortDir);
th.dataset.sortDir = dir;
rows.sort((a, b) => {
const av = a.children[colIdx]?.innerText.trim() || '';
const bv = b.children[colIdx]?.innerText.trim() || '';
const an = parseFloat(av.replace(/[^0-9.\\-]/g, ''));
const bn = parseFloat(bv.replace(/[^0-9.\\-]/g, ''));
let cmp;
if (!isNaN(an) && !isNaN(bn)) cmp = an - bn;
else cmp = av.localeCompare(bv);
return dir === 'asc' ? cmp : -cmp;
});
const totalRow = tbody.querySelector('tr.total-row');
rows.forEach((r) => tbody.appendChild(r));
if (totalRow) tbody.appendChild(totalRow);
});
});
});
</script>
</body>
</html>`;
}
// ─── emit ────────────────────────────────────────────────────────────────────
const markdown = renderMarkdown();
const html = renderHtml();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const mdPath = path.join(RESULTS_DIR, `compare-${stamp}.md`);
const htmlPath = path.join(RESULTS_DIR, `compare-${stamp}.html`);
fs.writeFileSync(mdPath, markdown, 'utf8');
fs.writeFileSync(htmlPath, html, 'utf8');
process.stdout.write(markdown);
console.error(`\n[bench-compare] markdown: ${path.relative(COMPLEX_DIR, mdPath)}`);
console.error(`[bench-compare] html: ${path.relative(COMPLEX_DIR, htmlPath)}`);
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env node
/* eslint-disable no-console, global-require */
/**
* Migration benchmark timing hook (Node `--require` preload).
*
* Captures per-migration start/end times by subscribing to Umzug's native
* `migrating` / `migrated` events. Umzug is a stable public dep of Strapi
* (currently 3.8.1); its event API is documented and unlikely to change,
* so this is more durable than patching anything inside @strapi/database.
*
* Strategy:
* 1. Patch Module._load so each `require('umzug')` returns a module whose
* `Umzug` export is a subclass that auto-attaches our listeners.
* 2. Accumulate timings in a module-level array.
* 3. On process exit, flush the array to STRAPI_BENCH_HOOK_OUTPUT (JSON).
*
* Activation:
* STRAPI_BENCH_HOOK_OUTPUT=/tmp/bench-$$.json \
* node --require /abs/path/to/bench-hook.js <strapi launcher>
*
* If STRAPI_BENCH_HOOK_OUTPUT is unset, the hook self-disables cheap to
* leave `--require` in dev configs without affecting normal runs.
*/
const fs = require('fs');
const Module = require('module');
const OUTPUT_PATH = process.env.STRAPI_BENCH_HOOK_OUTPUT;
const HOOK_DEBUG = process.env.STRAPI_BENCH_HOOK_DEBUG === '1';
function debug(...args) {
if (HOOK_DEBUG) {
console.error('[bench-hook]', ...args);
}
}
if (!OUTPUT_PATH) {
debug('STRAPI_BENCH_HOOK_OUTPUT not set — hook disabled');
return;
}
/** @type {Array<{name: string, startedAt: number, durationMs: number}>} */
const migrations = [];
/** @type {Map<string, number>} */
const inflight = new Map();
let umzugInstanceCount = 0;
function recordStart(name) {
inflight.set(name, performance.now());
}
function recordEnd(name) {
const start = inflight.get(name);
if (start == null) {
debug(`migrated event without matching migrating event: ${name}`);
return;
}
inflight.delete(name);
const durationMs = performance.now() - start;
migrations.push({
name,
startedAt: Date.now() - durationMs, // wall-clock approximation
durationMs,
});
debug(`recorded ${name}: ${durationMs.toFixed(2)}ms`);
// Flush incrementally — some Strapi shutdown paths bypass exit handlers
// (process.exit via signal or uncaught error), and we don't want to lose
// timing data we already collected.
flush();
}
/**
* Extract a migration name from an Umzug event payload.
* Umzug v3 emits `{name, path, context, ...}` sometimes just the name in older shapes.
*/
function getMigrationName(event) {
if (!event) return '<unknown>';
if (typeof event === 'string') return event;
if (typeof event.name === 'string') return event.name;
return '<unknown>';
}
/**
* Patch the Umzug prototype in place so every instance auto-attaches our
* listeners the first time its `up()` runs. This avoids cache-aliasing issues
* the subclass-then-replace-export approach would hit Node caches the
* original module object, so subsequent `require('umzug')` calls return the
* original class from cache, not our subclass wrapper.
*/
function instrumentUmzugClass(Umzug) {
if (!Umzug || typeof Umzug !== 'function' || Umzug.__strapiBenchHookPatched) {
return false;
}
const originalUp = Umzug.prototype.up;
if (typeof originalUp !== 'function') {
debug('Umzug.prototype.up is not a function — cannot patch');
return false;
}
Umzug.prototype.up = async function patchedUp(...args) {
if (!this.__strapiBenchHookListeners) {
this.__strapiBenchHookListeners = true;
umzugInstanceCount += 1;
try {
this.on('migrating', (evt) => recordStart(getMigrationName(evt)));
this.on('migrated', (evt) => recordEnd(getMigrationName(evt)));
debug(`attached listeners to Umzug instance #${umzugInstanceCount}`);
} catch (err) {
debug('failed to attach listeners:', err.message);
}
}
return originalUp.apply(this, args);
};
Object.defineProperty(Umzug, '__strapiBenchHookPatched', {
value: true,
enumerable: false,
});
return true;
}
/**
* Intercept require('umzug') to patch the class the moment it's first loaded
* before Strapi calls `new Umzug(...)`. Since we mutate the class prototype
* in place, subsequent loads that hit the cache still see the patched class.
*/
const originalLoad = Module._load;
Module._load = function patchedLoad(request, parent, ...rest) {
const mod = originalLoad.call(this, request, parent, ...rest);
if (request === 'umzug' && mod && mod.Umzug && !mod.Umzug.__strapiBenchHookPatched) {
const ok = instrumentUmzugClass(mod.Umzug);
if (ok) debug('patched umzug class prototype');
}
return mod;
};
/**
* Flush collected timings to disk. Sync write so we don't lose data if the
* parent process exits immediately after Strapi finishes migrating.
*/
function flush() {
const payload = {
hookVersion: 1,
instanceCount: umzugInstanceCount,
migrations,
capturedAt: new Date().toISOString(),
};
try {
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(payload, null, 2), 'utf8');
debug(`flushed ${migrations.length} migration entries to ${OUTPUT_PATH}`);
} catch (err) {
console.error('[bench-hook] failed to write output:', err.message);
}
}
process.on('exit', flush);
// Safety net — some Strapi boot failures call process.exit via uncaught errors.
process.on('SIGINT', () => {
flush();
process.exit(130);
});
process.on('SIGTERM', () => {
flush();
process.exit(143);
});
debug(`initialized — output: ${OUTPUT_PATH}`);
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Migration performance benchmark runner.
*
* Subcommands:
* seed wipe + boot v4 + seed + snapshot (one-time, expensive)
* run restore snapshot + run v5 migrations + record timings
* suite run baseline + candidate across all 4 DBs (chained runs)
*
* Strapi source resolution (currently only `local` is implemented):
* local use the monorepo workspace-linked @strapi/*
* To swap between branches: git checkout <branch> && yarn bench:run
* experimental install 0.0.0-experimental.<sha> into .bench-install/<ver>/
* (NOT YET IMPLEMENTED error thrown)
* pinned install a specific published version (NOT YET IMPLEMENTED)
*/
const { execSync, spawnSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { getDatabaseEnv } = require('./db-utils');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const MONOREPO_ROOT = path.resolve(COMPLEX_DIR, '../..');
const RESULTS_DIR = path.join(COMPLEX_DIR, 'results');
const V4_PROJECT_DIR = process.env.V4_OUTSIDE_DIR
? path.resolve(process.cwd(), process.env.V4_OUTSIDE_DIR)
: path.resolve(MONOREPO_ROOT, '..', 'complex-v4');
const SUPPORTED_DBS = ['postgres', 'mysql', 'mariadb', 'sqlite'];
// ─── arg parsing ──────────────────────────────────────────────────────────────
function parseArgs(argv) {
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith('--')) continue;
const eq = arg.indexOf('=');
if (eq !== -1) {
out[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
}
const next = argv[i + 1];
if (next == null || next.startsWith('--')) {
out[arg.slice(2)] = true;
} else {
out[arg.slice(2)] = next;
i += 1;
}
}
return out;
}
function requireArg(args, name, fallback) {
const v = args[name] ?? fallback;
if (v == null || v === '') {
console.error(`Error: --${name} is required`);
process.exit(1);
}
return v;
}
// ─── environment capture ──────────────────────────────────────────────────────
function captureEnv(db) {
const envInfo = {
nodeVersion: process.version,
platform: process.platform,
arch: process.arch,
cpuModel: os.cpus()[0]?.model || 'unknown',
cpuCount: os.cpus().length,
totalMemMB: Math.round(os.totalmem() / (1024 * 1024)),
dbEngine: db,
dbVersion: null,
dbHostType: db === 'sqlite' ? 'local-file' : 'local-container',
};
// Best-effort DB version probe — don't fail the benchmark if this breaks.
try {
if (db === 'sqlite') {
// eslint-disable-next-line global-require
const Database = require('better-sqlite3');
envInfo.dbVersion = Database.prototype.constructor.name
? require('better-sqlite3/package.json').version
: null;
} else {
const { runContainer } = require('./compose');
const containerLookup = {
postgres: ['ps', '--filter', 'name=strapi_complex_postgres', '--format', '{{.ID}}'],
mysql: ['ps', '--filter', 'name=strapi_complex_mysql', '--format', '{{.ID}}'],
mariadb: ['ps', '--filter', 'name=strapi_complex_mariadb', '--format', '{{.ID}}'],
};
const idRaw = runContainer(containerLookup[db]).trim();
const id = idRaw.split('\n').filter(Boolean)[0];
if (id) {
// Force TCP (-h 127.0.0.1) for mysql/mariadb because their CLIs default
// to a unix-socket path that doesn't exist in the official images.
const probeCmd = {
postgres: ['exec', id, 'psql', '-U', 'strapi', '-t', '-c', 'SHOW server_version;'],
mysql: [
'exec',
id,
'mysql',
'-h',
'127.0.0.1',
'-ustrapi',
'-pstrapi',
'-sN',
'-e',
'SELECT VERSION();',
],
mariadb: [
'exec',
id,
'mariadb',
'-h',
'127.0.0.1',
'-ustrapi',
'-pstrapi',
'-sN',
'-e',
'SELECT VERSION();',
],
};
envInfo.dbVersion = runContainer(probeCmd[db]).trim().split('\n')[0];
}
}
} catch (err) {
envInfo.dbVersionProbeError = String(err.message || err);
}
return envInfo;
}
function captureStrapiSource() {
// Read @strapi/strapi version actually resolved (so `local` reports the
// monorepo's current version + branch + sha).
let version = null;
try {
// eslint-disable-next-line global-require
version = require('@strapi/strapi/package.json').version;
} catch {
/* leave null */
}
let gitSha = null;
let gitBranch = null;
try {
gitSha = execSync('git rev-parse HEAD', { cwd: MONOREPO_ROOT, encoding: 'utf8' }).trim();
} catch {
/* leave null */
}
try {
gitBranch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: MONOREPO_ROOT,
encoding: 'utf8',
}).trim();
} catch {
/* leave null */
}
return {
strapiSource: 'local',
strapiVersion: version,
strapiGitSha: gitSha,
strapiGitBranch: gitBranch,
};
}
// ─── snapshot helpers ─────────────────────────────────────────────────────────
function snapshotExists(db, name) {
if (db === 'sqlite') {
return fs.existsSync(path.join(COMPLEX_DIR, 'snapshots', `sqlite-${name}.db`));
}
return fs.existsSync(path.join(COMPLEX_DIR, 'snapshots', `${db}-${name}.sql`));
}
function restoreSnapshot(db, name) {
const script = `db-${db}.js`;
console.log(`Restoring ${db} snapshot "${name}"...`);
execSync(`node ${path.join('scripts', script)} restore ${name}`, {
cwd: COMPLEX_DIR,
stdio: 'inherit',
});
}
// ─── row count collection ─────────────────────────────────────────────────────
function collectRowCounts(db) {
const script = `db-${db}.js`;
try {
const output = execSync(`node ${path.join('scripts', script)} check`, {
cwd: COMPLEX_DIR,
encoding: 'utf8',
});
// Parse the "Table Name | Row Count" table produced by db:check.
const rowCounts = {};
const lines = output.split('\n');
let inTable = false;
for (const line of lines) {
if (line.includes('---|')) {
inTable = true;
continue;
}
if (!inTable) continue;
const m = line.match(/^([^|]+?)\s*\|\s*(\d+)\s*$/);
if (m) rowCounts[m[1].trim()] = Number(m[2]);
}
return rowCounts;
} catch (err) {
return { _error: String(err.message || err) };
}
}
// ─── migrate-then-exit runner ─────────────────────────────────────────────────
/**
* Spawn Strapi, wait for it to finish bootstrapping (which runs migrations),
* then cleanly tear down. Uses the bench-hook preload to capture timings.
*/
function runMigrationsOnce(db, hookOutputPath) {
const env = {
...getDatabaseEnv(db),
STRAPI_BENCH_HOOK_OUTPUT: hookOutputPath,
STRAPI_BENCH_HOOK_DEBUG: process.env.STRAPI_BENCH_HOOK_DEBUG || '',
// Silence unrelated noise for cleaner logs; Strapi's own logger is still active.
STRAPI_TELEMETRY_DISABLED: '1',
STRAPI_DISABLE_UPDATE_NOTIFIER: '1',
};
// Strapi configs in examples/complex are .ts — compile them to dist/ first
// (same path Strapi's own CLI uses via `strapi build` / `strapi develop`).
// Then boot Strapi pointing at the compiled output.
const script = `
const tsUtils = require('@strapi/typescript-utils');
const { createStrapi } = require('@strapi/strapi');
(async () => {
const cwd = process.cwd();
if (await tsUtils.isUsingTypeScript(cwd)) {
await tsUtils.compile(cwd, { configOptions: { ignoreDiagnostics: true } });
}
const distDir = await tsUtils.resolveOutDir(cwd);
const app = await createStrapi({ distDir }).load();
// Migrations ran during load(). Tear down cleanly.
await app.destroy();
})().catch((err) => {
console.error('[bench] strapi boot failed:', err);
process.exit(1);
});
`;
const hookPath = path.resolve(SCRIPT_DIR, 'bench-hook.js');
const start = performance.now();
const result = spawnSync('node', ['--require', hookPath, '-e', script], {
cwd: COMPLEX_DIR,
env,
stdio: 'inherit',
});
const wallMs = performance.now() - start;
if (result.status !== 0) {
console.error(`[bench] strapi boot exited with status ${result.status}`);
process.exit(result.status ?? 1);
}
return { wallMs };
}
// ─── subcommand: run ──────────────────────────────────────────────────────────
function cmdRun(args) {
const db = requireArg(args, 'db');
if (!SUPPORTED_DBS.includes(db)) {
console.error(`Error: --db must be one of: ${SUPPORTED_DBS.join(', ')}`);
process.exit(1);
}
const label = requireArg(args, 'label');
const snapshot = args.snapshot || `bench-m${args.multiplier || 1}`;
const multiplier = Number(args.multiplier ?? 1);
const strapiSource = args['strapi-source'] || 'local';
if (strapiSource !== 'local') {
console.error(
`Error: --strapi-source=${strapiSource} is not yet implemented. Use \`local\` and swap branches with \`git checkout\` / \`gh pr checkout\`.`
);
process.exit(1);
}
if (!snapshotExists(db, snapshot)) {
console.error(`Error: snapshot "${snapshot}" does not exist for ${db}.`);
console.error(`Run \`yarn bench:seed --db ${db} --multiplier ${multiplier}\` first.`);
process.exit(1);
}
// Ensure the results dir exists before the hook tries to write.
if (!fs.existsSync(RESULTS_DIR)) fs.mkdirSync(RESULTS_DIR, { recursive: true });
// Restore DB
restoreSnapshot(db, snapshot);
// Run migrations with timing hook
const hookOut = path.join(os.tmpdir(), `strapi-bench-hook-${process.pid}-${Date.now()}.json`);
console.log(`[bench] running migrations against ${db} (label=${label})...`);
const { wallMs } = runMigrationsOnce(db, hookOut);
// Ingest hook output
let hookData = { migrations: [], instanceCount: 0 };
if (fs.existsSync(hookOut)) {
try {
hookData = JSON.parse(fs.readFileSync(hookOut, 'utf8'));
} catch (err) {
console.error(`[bench] failed to parse hook output: ${err.message}`);
}
fs.unlinkSync(hookOut);
}
if (!hookData.migrations.length) {
console.warn(
'[bench] WARNING: hook captured zero migrations. Either Umzug API changed, or migrations were skipped as already-applied. Check the output below.'
);
}
const totalDurationMs = hookData.migrations.reduce((a, m) => a + m.durationMs, 0);
const rowCount = collectRowCounts(db);
const timestamp = new Date().toISOString();
const result = {
timestamp,
label,
...captureStrapiSource(),
strapiSource,
env: captureEnv(db),
config: {
multiplier,
snapshot,
seedMode: 'knex',
hookMode: 'prototype',
},
rowCount,
migrations: hookData.migrations,
totalDurationMs,
wallDurationMs: wallMs,
umzugInstanceCount: hookData.instanceCount,
};
const outFile = path.join(RESULTS_DIR, `${db}-${label}-${timestamp.replace(/[:.]/g, '-')}.json`);
fs.writeFileSync(outFile, JSON.stringify(result, null, 2), 'utf8');
console.log(`\n✅ Benchmark complete.`);
console.log(` DB: ${db}`);
console.log(` Label: ${label}`);
console.log(` Migrations: ${hookData.migrations.length}`);
console.log(` Total: ${totalDurationMs.toFixed(1)} ms (wall ${wallMs.toFixed(0)} ms)`);
console.log(` Result: ${path.relative(COMPLEX_DIR, outFile)}`);
}
// ─── subcommand: seed ─────────────────────────────────────────────────────────
function cmdSeed(args) {
const db = requireArg(args, 'db');
if (!SUPPORTED_DBS.includes(db)) {
console.error(`Error: --db must be one of: ${SUPPORTED_DBS.join(', ')}`);
process.exit(1);
}
const multiplier = Number(args.multiplier ?? 1);
const seedMode = args['seed-mode'] || 'strapi'; // knex mode is a follow-on
const snapshotName = `bench-m${multiplier}`;
if (seedMode !== 'strapi') {
console.error(
`Error: --seed-mode=${seedMode} is not yet implemented. Only \`strapi\` is available in the first iteration. The knex fast-seed path is tracked as a follow-up.`
);
process.exit(1);
}
if (!fs.existsSync(V4_PROJECT_DIR)) {
console.error(`Error: v4 project not found at ${V4_PROJECT_DIR}. Run \`yarn setup:v4\` first.`);
process.exit(1);
}
// Step 1: wipe DB (destructive; explicit per plan)
console.log(`[bench:seed] wiping ${db}...`);
execSync(`node ${path.join('scripts', `db-${db}.js`)} wipe`, {
cwd: COMPLEX_DIR,
stdio: 'inherit',
});
// Step 2: run v4 seed via the v4 project's seed-with-db wrapper.
// This boots Strapi v4, which creates the schema + bootstrap data, then runs seed.js.
console.log(`[bench:seed] seeding via Strapi v4 API (multiplier=${multiplier})...`);
execSync(`node scripts/seed-with-db.js ${db} ${multiplier}`, {
cwd: V4_PROJECT_DIR,
stdio: 'inherit',
});
// Step 3: snapshot
console.log(`[bench:seed] snapshotting as "${snapshotName}"...`);
execSync(`node ${path.join('scripts', `db-${db}.js`)} snapshot ${snapshotName}`, {
cwd: COMPLEX_DIR,
stdio: 'inherit',
});
console.log(`\n✅ Seed + snapshot ready: ${db}/${snapshotName}`);
console.log(` Run \`yarn bench:run --db ${db} --label <label>\` to benchmark.`);
}
// ─── subcommand: suite ────────────────────────────────────────────────────────
function cmdSuite(args) {
const multiplier = Number(args.multiplier ?? 1);
const baselineLabel = args.baseline || 'baseline';
const candidateLabel = args.candidate || 'candidate';
const dbs = (args.dbs || SUPPORTED_DBS.join(',')).split(',').filter(Boolean);
console.log(
`[bench:suite] running ${baselineLabel} + ${candidateLabel} across ${dbs.join(', ')} @ multiplier=${multiplier}`
);
console.log(
`[bench:suite] ASSUMPTION: you have already seeded each DB (\`yarn bench:seed --db <db> --multiplier ${multiplier}\`).`
);
console.log(
`[bench:suite] ASSUMPTION: you switch the monorepo checkout between baseline and candidate manually BEFORE invoking this.`
);
for (const db of dbs) {
if (!SUPPORTED_DBS.includes(db)) {
console.error(`[bench:suite] skipping unknown db: ${db}`);
continue;
}
console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
console.log(`[bench:suite] ${db}: running under current strapi checkout...`);
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
// The suite subcommand runs under whatever branch is checked out. The
// caller is responsible for sequencing baseline vs candidate runs.
const label = args.label || baselineLabel;
cmdRun({ db, label, multiplier, snapshot: `bench-m${multiplier}` });
}
}
// ─── main ─────────────────────────────────────────────────────────────────────
const subcommand = process.argv[2];
const rest = process.argv.slice(3);
const args = parseArgs(rest);
switch (subcommand) {
case 'run':
cmdRun(args);
break;
case 'seed':
cmdSeed(args);
break;
case 'suite':
cmdSuite(args);
break;
default:
console.error('Usage: node bench.js <run|seed|suite> [options]');
console.error('');
console.error(' run --db <db> --label <label> [--multiplier <n>] [--snapshot <name>]');
console.error(' seed --db <db> --multiplier <n> [--seed-mode strapi|knex]');
console.error(
' suite --multiplier <n> [--dbs postgres,mysql,mariadb,sqlite] [--label <tag>]'
);
process.exit(1);
}
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env node
/* eslint-disable no-console */
/**
* Runtime auto-detection for docker/podman + compose CLIs.
*
* Callers should not know whether the user has docker or podman installed;
* they just call runCompose(args) / runContainer(args) and this module picks
* the right binary. Preference order:
*
* Compose: podman compose > podman-compose > docker compose > docker-compose
* Container: podman > docker
*
* Override via env var STRAPI_BENCH_RUNTIME=podman|docker if auto-detection
* picks the wrong one on a mixed-install system.
*/
const { execFileSync, spawnSync } = require('child_process');
function probe(exe, args = ['--version']) {
const result = spawnSync(exe, args, { stdio: 'ignore' });
return result.status === 0;
}
function probeSubcommand(exe, sub) {
// Some binaries (podman/docker) have their compose functionality as a subcommand.
// `<exe> compose version` exits 0 when the subcommand is available, nonzero otherwise.
const result = spawnSync(exe, [sub, 'version'], { stdio: 'ignore' });
return result.status === 0;
}
let detectedCompose = null;
let detectedContainer = null;
function detectCompose() {
if (detectedCompose) return detectedCompose;
const override = process.env.STRAPI_BENCH_RUNTIME;
const tryOrder = [];
if (override === 'podman') {
tryOrder.push(['podman', ['compose'], 'podman compose']);
tryOrder.push(['podman-compose', [], 'podman-compose']);
} else if (override === 'docker') {
tryOrder.push(['docker', ['compose'], 'docker compose']);
tryOrder.push(['docker-compose', [], 'docker-compose']);
} else {
// Auto: prefer podman first (user preference)
tryOrder.push(['podman', ['compose'], 'podman compose']);
tryOrder.push(['podman-compose', [], 'podman-compose']);
tryOrder.push(['docker', ['compose'], 'docker compose']);
tryOrder.push(['docker-compose', [], 'docker-compose']);
}
for (const [exe, prefix, label] of tryOrder) {
const available = prefix.length > 0 ? probeSubcommand(exe, prefix[0]) : probe(exe);
if (available) {
detectedCompose = { exe, prefixArgs: prefix, label };
return detectedCompose;
}
}
throw new Error(
'No compose runtime found. Install one of: podman (with podman-compose or podman v4+ built-in compose), docker compose, or docker-compose.'
);
}
function detectContainer() {
if (detectedContainer) return detectedContainer;
const override = process.env.STRAPI_BENCH_RUNTIME;
const tryOrder = override === 'docker' ? ['docker', 'podman'] : ['podman', 'docker'];
for (const exe of tryOrder) {
if (probe(exe)) {
detectedContainer = exe;
return detectedContainer;
}
}
throw new Error('No container runtime found. Install podman or docker.');
}
/**
* Run a compose command. Args are appended after the detected prefix.
*
* runCompose(['-f', 'docker-compose.dev.yml', 'up', '-d', 'postgres'], {cwd, env})
*
* Returns stdout (utf8). Pass opts.stdio='inherit' to stream to terminal.
*/
function runCompose(args, opts = {}) {
const { exe, prefixArgs } = detectCompose();
const finalArgs = [...prefixArgs, ...args];
return execFileSync(exe, finalArgs, {
encoding: 'utf8',
stdio: 'pipe',
...opts,
});
}
/**
* Run a container command (exec, inspect, ps, etc.) against the detected runtime.
*
* runContainer(['exec', containerId, 'pg_isready'])
* runContainer(['inspect', '--format={{.State.Running}}', containerId])
*/
function runContainer(args, opts = {}) {
const exe = detectContainer();
return execFileSync(exe, args, {
encoding: 'utf8',
stdio: 'pipe',
...opts,
});
}
/**
* Lower-level accessor use when you need to build the command yourself
* (e.g., passing to spawnSync for silent probes).
*/
function getComposeCommand() {
return detectCompose();
}
function getContainerCommand() {
return detectContainer();
}
function describeRuntime() {
const compose = detectCompose();
const container = detectContainer();
return `compose: ${compose.label}, container: ${container}`;
}
module.exports = {
runCompose,
runContainer,
getComposeCommand,
getContainerCommand,
describeRuntime,
};
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const {
getContainerName: getComposeContainerName,
getComposeEnv,
COMPOSE_PROJECT_NAME,
} = require('./db-utils');
const { getComposeCommand, getContainerCommand } = require('./compose');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const DOCKER_COMPOSE_FILE = path.join(COMPLEX_DIR, 'docker-compose.dev.yml');
const SNAPSHOTS_DIR = path.join(COMPLEX_DIR, 'snapshots');
const DB_NAME = process.env.DATABASE_NAME || 'strapi';
const DB_USER = process.env.DATABASE_USERNAME || 'strapi';
const DB_PASSWORD = process.env.DATABASE_PASSWORD || 'strapi';
function composeCmd() {
const { exe, prefixArgs } = getComposeCommand();
return [exe, ...prefixArgs].join(' ');
}
function containerCmd() {
return getContainerCommand();
}
function getContainerName() {
const name = getComposeContainerName(DOCKER_COMPOSE_FILE, COMPLEX_DIR, 'mariadb');
if (!name) {
throw new Error(
`MariaDB container not found. Start it with "yarn db:start:mariadb" (COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}).`
);
}
return name;
}
const command = process.argv[2];
const snapshotName = process.argv[3];
function ensureSnapshotsDir() {
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
}
}
function execShell(cmd) {
try {
execSync(cmd, { stdio: 'inherit', cwd: COMPLEX_DIR, env: getComposeEnv(), shell: '/bin/bash' });
} catch (error) {
console.error(`Error executing: ${cmd}`);
process.exit(1);
}
}
switch (command) {
case 'start':
console.log('Starting mariadb container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} up -d mariadb`);
console.log('✅ MariaDB started');
break;
case 'stop':
console.log('Stopping mariadb container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} stop mariadb`);
console.log('✅ MariaDB stopped');
break;
case 'snapshot':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-mariadb.js snapshot <name>');
process.exit(1);
}
ensureSnapshotsDir();
const snapshotPath = path.join(SNAPSHOTS_DIR, `mariadb-${snapshotName}.sql`);
console.log(`Creating snapshot: ${snapshotName}...`);
{
const containerName = getContainerName();
try {
// `mariadb-dump` is the modern binary; older images fall back to `mysqldump` alias.
execSync(
`${containerCmd()} exec ${containerName} mariadb-dump -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} > ${snapshotPath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot created: ${snapshotPath}`);
} catch (error) {
console.error(`Error creating snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'restore':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-mariadb.js restore <name>');
process.exit(1);
}
const restorePath = path.join(SNAPSHOTS_DIR, `mariadb-${snapshotName}.sql`);
if (!fs.existsSync(restorePath)) {
console.error(`Error: Snapshot not found: ${restorePath}`);
process.exit(1);
}
console.log(`Restoring snapshot: ${snapshotName}...`);
{
const containerName = getContainerName();
try {
// Drop and recreate database
execSync(
`${containerCmd()} exec ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -e "DROP DATABASE IF EXISTS ${DB_NAME}; CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
// Restore from snapshot
execSync(
`${containerCmd()} exec -i ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} < ${restorePath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot restored: ${snapshotName}`);
} catch (error) {
console.error(`Error restoring snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'wipe':
console.log('Wiping mariadb database...');
{
const containerName = getContainerName();
try {
execSync(
`${containerCmd()} exec ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -e "DROP DATABASE IF EXISTS ${DB_NAME}; CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log('✅ Database wiped');
} catch (error) {
console.error(`Error wiping database: ${error.message}`);
process.exit(1);
}
}
break;
case 'check':
{
const containerName = getContainerName();
try {
// Refresh information_schema.tables.table_rows via ANALYZE TABLE;
// without it, stats can lag reality by hours.
const analyzeQuery = `
SELECT CONCAT('ANALYZE TABLE ', table_name, ';') AS cmd
FROM information_schema.tables
WHERE table_schema = '${DB_NAME}';
`;
try {
const analyzeList = execSync(
`${containerCmd()} exec ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${analyzeQuery.trim()}" -s -N`,
{ encoding: 'utf8', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
const cmds = analyzeList.trim().split('\n').filter(Boolean).join(' ');
if (cmds) {
execSync(
`${containerCmd()} exec ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${cmds}"`,
{ stdio: 'ignore', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
}
} catch {
// Best-effort; fall through to stale stats rather than fail.
}
const query = `
SELECT
table_name,
table_rows
FROM information_schema.tables
WHERE table_schema = '${DB_NAME}'
ORDER BY table_name;
`;
const output = execSync(
`${containerCmd()} exec ${containerName} mariadb -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${query.trim()}" -s -N`,
{ encoding: 'utf8', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
const lines = output
.trim()
.split('\n')
.filter((l) => l.trim());
if (lines.length === 0) {
console.log('📊 No tables found (database is empty or wiped)');
} else {
console.log('📊 Database Tables (approximate row counts):\n');
console.log('Table Name | Row Count');
console.log('------------------------------------|----------');
for (const line of lines) {
const [table, count] = line.trim().split('\t');
const paddedName = (table || '').padEnd(35);
const rowCount = count || '0';
console.log(`${paddedName} | ${rowCount}`);
}
}
} catch (error) {
console.error(`Error checking database: ${error.message}`);
process.exit(1);
}
}
break;
default:
console.error('Error: Unknown command');
console.error('Usage: node db-mariadb.js <start|stop|snapshot|restore|wipe|check> [name]');
process.exit(1);
}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const {
getContainerName: getComposeContainerName,
getComposeEnv,
COMPOSE_PROJECT_NAME,
} = require('./db-utils');
const { getComposeCommand, getContainerCommand } = require('./compose');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const DOCKER_COMPOSE_FILE = path.join(COMPLEX_DIR, 'docker-compose.dev.yml');
const SNAPSHOTS_DIR = path.join(COMPLEX_DIR, 'snapshots');
const DB_NAME = process.env.DATABASE_NAME || 'strapi';
const DB_USER = process.env.DATABASE_USERNAME || 'strapi';
const DB_PASSWORD = process.env.DATABASE_PASSWORD || 'strapi';
function composeCmd() {
const { exe, prefixArgs } = getComposeCommand();
return [exe, ...prefixArgs].join(' ');
}
function containerCmd() {
return getContainerCommand();
}
// Try to find the container name dynamically, fallback to expected name
function getContainerName() {
const name = getComposeContainerName(DOCKER_COMPOSE_FILE, COMPLEX_DIR, 'mysql');
if (!name) {
throw new Error(
`MySQL container not found. Start it with "yarn db:start:mysql" (COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}).`
);
}
return name;
}
const command = process.argv[2];
const snapshotName = process.argv[3];
function ensureSnapshotsDir() {
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
}
}
function execShell(cmd) {
try {
execSync(cmd, { stdio: 'inherit', cwd: COMPLEX_DIR, env: getComposeEnv(), shell: '/bin/bash' });
} catch (error) {
console.error(`Error executing: ${cmd}`);
process.exit(1);
}
}
switch (command) {
case 'start':
console.log('Starting mysql container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} up -d mysql`);
console.log('✅ MySQL started');
break;
case 'stop':
console.log('Stopping mysql container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} stop mysql`);
console.log('✅ MySQL stopped');
break;
case 'snapshot':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-mysql.js snapshot <name>');
process.exit(1);
}
ensureSnapshotsDir();
const snapshotPath = path.join(SNAPSHOTS_DIR, `mysql-${snapshotName}.sql`);
console.log(`Creating snapshot: ${snapshotName}...`);
{
const containerName = getContainerName();
try {
execSync(
`${containerCmd()} exec ${containerName} mysqldump -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} > ${snapshotPath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot created: ${snapshotPath}`);
} catch (error) {
console.error(`Error creating snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'restore':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-mysql.js restore <name>');
process.exit(1);
}
const restorePath = path.join(SNAPSHOTS_DIR, `mysql-${snapshotName}.sql`);
if (!fs.existsSync(restorePath)) {
console.error(`Error: Snapshot not found: ${restorePath}`);
process.exit(1);
}
console.log(`Restoring snapshot: ${snapshotName}...`);
{
const containerName = getContainerName();
try {
// Drop and recreate database
execSync(
`${containerCmd()} exec ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -e "DROP DATABASE IF EXISTS ${DB_NAME}; CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
// Restore from snapshot
execSync(
`${containerCmd()} exec -i ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} ${DB_NAME} < ${restorePath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot restored: ${snapshotName}`);
} catch (error) {
console.error(`Error restoring snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'wipe':
console.log('Wiping mysql database...');
{
const containerName = getContainerName();
try {
execSync(
`${containerCmd()} exec ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -e "DROP DATABASE IF EXISTS ${DB_NAME}; CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log('✅ Database wiped');
} catch (error) {
console.error(`Error wiping database: ${error.message}`);
process.exit(1);
}
}
break;
case 'check':
{
const containerName = getContainerName();
try {
// Refresh information_schema.tables.table_rows with fresh stats.
// The default values can lag reality by hours, which is unacceptable
// for benchmark reports. ANALYZE TABLE triggers a stats refresh.
const analyzeQuery = `
SELECT CONCAT('ANALYZE TABLE ', table_name, ';') AS cmd
FROM information_schema.tables
WHERE table_schema = '${DB_NAME}';
`;
try {
const analyzeList = execSync(
`${containerCmd()} exec ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${analyzeQuery.trim()}" -s -N`,
{ encoding: 'utf8', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
const cmds = analyzeList.trim().split('\n').filter(Boolean).join(' ');
if (cmds) {
execSync(
`${containerCmd()} exec ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${cmds}"`,
{ stdio: 'ignore', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
}
} catch {
// Best-effort; fall through to stale stats rather than fail the check.
}
const query = `
SELECT
table_name,
table_rows
FROM information_schema.tables
WHERE table_schema = '${DB_NAME}'
ORDER BY table_name;
`;
const output = execSync(
`${containerCmd()} exec ${containerName} mysql -h 127.0.0.1 -u${DB_USER} -p${DB_PASSWORD} -D ${DB_NAME} -e "${query.trim()}" -s -N`,
{ encoding: 'utf8', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
const lines = output
.trim()
.split('\n')
.filter((l) => l.trim());
if (lines.length === 0) {
console.log('📊 No tables found (database is empty or wiped)');
} else {
console.log('📊 Database Tables (approximate row counts):\n');
console.log('Table Name | Row Count');
console.log('------------------------------------|----------');
for (const line of lines) {
const [table, count] = line.trim().split('\t');
const paddedName = (table || '').padEnd(35);
const rowCount = count || '0';
console.log(`${paddedName} | ${rowCount}`);
}
}
} catch (error) {
console.error(`Error checking database: ${error.message}`);
process.exit(1);
}
}
break;
default:
console.error('Error: Unknown command');
console.error('Usage: node db-mysql.js <start|stop|snapshot|restore|wipe|check> [name]');
process.exit(1);
}
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env node
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const {
getContainerName: getComposeContainerName,
getComposeEnv,
startContainer,
COMPOSE_PROJECT_NAME,
} = require('./db-utils');
const { getComposeCommand, getContainerCommand } = require('./compose');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const DOCKER_COMPOSE_FILE = path.join(COMPLEX_DIR, 'docker-compose.dev.yml');
const SNAPSHOTS_DIR = path.join(COMPLEX_DIR, 'snapshots');
const DB_NAME = process.env.DATABASE_NAME || 'strapi';
const DB_USER = process.env.DATABASE_USERNAME || 'strapi';
/**
* Build a shell-safe compose command prefix string.
* e.g. "podman compose" or "docker-compose" or "docker compose"
*/
function composeCmd() {
const { exe, prefixArgs } = getComposeCommand();
return [exe, ...prefixArgs].join(' ');
}
/**
* Container runtime binary for `<runtime> exec <container> ...` invocations.
*/
function containerCmd() {
return getContainerCommand();
}
// Try to find the container name dynamically, fallback to expected name
function resolveContainerName() {
const name = getComposeContainerName(DOCKER_COMPOSE_FILE, COMPLEX_DIR, 'postgres');
if (!name) {
throw new Error(
`Postgres container not found. Start it with "yarn db:start:postgres" (COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME}).`
);
}
return name;
}
const command = process.argv[2];
const snapshotName = process.argv[3];
function ensureSnapshotsDir() {
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
}
}
function execShell(cmd) {
try {
execSync(cmd, { stdio: 'inherit', cwd: COMPLEX_DIR, env: getComposeEnv(), shell: '/bin/bash' });
} catch (error) {
console.error(`Error executing: ${cmd}`);
process.exit(1);
}
}
switch (command) {
case 'start':
console.log('Starting postgres container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} up -d postgres`);
console.log('✅ Postgres started');
break;
case 'stop':
console.log('Stopping postgres container...');
execShell(`${composeCmd()} -f ${DOCKER_COMPOSE_FILE} stop postgres`);
console.log('✅ Postgres stopped');
break;
case 'snapshot':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-postgres.js snapshot <name>');
process.exit(1);
}
ensureSnapshotsDir();
const snapshotPath = path.join(SNAPSHOTS_DIR, `postgres-${snapshotName}.sql`);
console.log(`Creating snapshot: ${snapshotName}...`);
{
const containerName = resolveContainerName();
try {
execSync(
`${containerCmd()} exec ${containerName} pg_dump -U ${DB_USER} -d ${DB_NAME} > ${snapshotPath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot created: ${snapshotPath}`);
} catch (error) {
console.error(`Error creating snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'restore':
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-postgres.js restore <name>');
process.exit(1);
}
const restorePath = path.join(SNAPSHOTS_DIR, `postgres-${snapshotName}.sql`);
if (!fs.existsSync(restorePath)) {
console.error(`Error: Snapshot not found: ${restorePath}`);
process.exit(1);
}
console.log(`Restoring snapshot: ${snapshotName}...`);
{
const containerName = resolveContainerName();
try {
// Drop and recreate database
execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d postgres -c "DROP DATABASE IF EXISTS ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d postgres -c "CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
// Restore from snapshot
execSync(
`${containerCmd()} exec -i ${containerName} psql -U ${DB_USER} -d ${DB_NAME} < ${restorePath}`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log(`✅ Snapshot restored: ${snapshotName}`);
} catch (error) {
console.error(`Error restoring snapshot: ${error.message}`);
process.exit(1);
}
}
break;
case 'wipe':
// Ensure container is running first
console.log('Ensuring postgres container is running...');
try {
startContainer(DOCKER_COMPOSE_FILE, COMPLEX_DIR, 'postgres');
// Wait a moment for container to be ready
execSync('sleep 2', { stdio: 'inherit' });
} catch (error) {
console.error(`Error starting postgres container: ${error.message}`);
process.exit(1);
}
console.log('Wiping postgres database...');
{
const containerName = resolveContainerName();
try {
execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d postgres -c "DROP DATABASE IF EXISTS ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d postgres -c "CREATE DATABASE ${DB_NAME};"`,
{ stdio: 'inherit', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
console.log('✅ Database wiped');
} catch (error) {
console.error(`Error wiping database: ${error.message}`);
process.exit(1);
}
}
break;
case 'check':
{
const containerName = resolveContainerName();
try {
// Refresh pg_stat_user_tables with fresh row-count estimates before
// reading — without ANALYZE the n_live_tup numbers can lag reality
// by minutes (autovacuum interval), which is unacceptable for a
// benchmark report that publishes row counts.
execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d ${DB_NAME} -c "ANALYZE;"`,
{ stdio: 'ignore', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
// Use pg_stat_user_tables statistics for fast approximate counts
const query = `
SELECT
schemaname||'.'||relname as table_name,
COALESCE(n_live_tup, 0)::text as row_count
FROM pg_stat_user_tables
ORDER BY schemaname, relname;
`;
const output = execSync(
`${containerCmd()} exec ${containerName} psql -U ${DB_USER} -d ${DB_NAME} -t -c "${query.trim()}"`,
{ encoding: 'utf8', cwd: COMPLEX_DIR, shell: '/bin/bash' }
);
const lines = output
.trim()
.split('\n')
.filter((l) => l.trim());
if (lines.length === 0) {
console.log('📊 No tables found (database is empty or wiped)');
} else {
console.log('📊 Database Tables (approximate row counts):\n');
console.log('Table Name | Row Count');
console.log('------------------------------------|----------');
for (const line of lines) {
const [table, count] = line
.trim()
.split('|')
.map((s) => s.trim());
const tableName = table.replace(/^public\./, ''); // Remove schema prefix
const paddedName = tableName.padEnd(35);
console.log(`${paddedName} | ${count}`);
}
}
} catch (error) {
console.error(`Error checking database: ${error.message}`);
process.exit(1);
}
}
break;
default:
console.error('Error: Unknown command');
console.error('Usage: node db-postgres.js <start|stop|snapshot|restore|wipe|check> [name]');
process.exit(1);
}
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const SNAPSHOTS_DIR = path.join(COMPLEX_DIR, 'snapshots');
/**
* SQLite lives entirely as files; no container and no compose runtime.
*
* The v4 project resolves its DB filename relative to its own cwd, so a
* pair-compatible path is written to the v4 scaffold in setup-v4-project.js.
* For operations run from here in the v5 `complex/` project, we use the same
* filename so snapshots produced by either side are interchangeable.
*/
const DATABASE_FILENAME =
process.env.SQLITE_DATABASE_FILENAME ||
process.env.DATABASE_FILENAME ||
path.join(COMPLEX_DIR, '..', 'complex-v4', '.tmp', 'data.db');
const command = process.argv[2];
const snapshotName = process.argv[3];
function ensureSnapshotsDir() {
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true });
}
}
function snapshotPath(name) {
return path.join(SNAPSHOTS_DIR, `sqlite-${name}.db`);
}
function requireBetterSqlite() {
try {
// eslint-disable-next-line global-require
return require('better-sqlite3');
} catch (error) {
throw new Error(
'better-sqlite3 is required for sqlite operations. It is a peer dep of @strapi/strapi and should be present via workspace hoisting; if not, install it in examples/complex.'
);
}
}
switch (command) {
case 'start':
// No-op: sqlite is file-based.
console.log('✅ SQLite is file-based; nothing to start.');
console.log(` Database file: ${DATABASE_FILENAME}`);
break;
case 'stop':
// No-op: sqlite is file-based.
console.log('✅ SQLite is file-based; nothing to stop.');
break;
case 'snapshot': {
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-sqlite.js snapshot <name>');
process.exit(1);
}
if (!fs.existsSync(DATABASE_FILENAME)) {
console.error(`Error: Database file not found: ${DATABASE_FILENAME}`);
console.error('Run the v4 app with `yarn develop:sqlite` and seed first to create it.');
process.exit(1);
}
ensureSnapshotsDir();
const target = snapshotPath(snapshotName);
fs.copyFileSync(DATABASE_FILENAME, target);
console.log(`✅ Snapshot created: ${target}`);
break;
}
case 'restore': {
if (!snapshotName) {
console.error('Error: Snapshot name is required');
console.error('Usage: node db-sqlite.js restore <name>');
process.exit(1);
}
const source = snapshotPath(snapshotName);
if (!fs.existsSync(source)) {
console.error(`Error: Snapshot not found: ${source}`);
process.exit(1);
}
// Remove walk-ahead / shared-memory sidecars so they don't conflict with the
// restored file's transaction state.
for (const suffix of ['', '-wal', '-shm', '-journal']) {
const sidecar = `${DATABASE_FILENAME}${suffix}`;
if (fs.existsSync(sidecar)) {
try {
fs.unlinkSync(sidecar);
} catch {
/* best-effort */
}
}
}
fs.mkdirSync(path.dirname(DATABASE_FILENAME), { recursive: true });
fs.copyFileSync(source, DATABASE_FILENAME);
console.log(`✅ Snapshot restored: ${snapshotName} -> ${DATABASE_FILENAME}`);
break;
}
case 'wipe':
for (const suffix of ['', '-wal', '-shm', '-journal']) {
const f = `${DATABASE_FILENAME}${suffix}`;
if (fs.existsSync(f)) {
fs.unlinkSync(f);
}
}
console.log(`✅ SQLite database file removed: ${DATABASE_FILENAME}`);
break;
case 'check': {
if (!fs.existsSync(DATABASE_FILENAME)) {
console.log('📊 No database file found (empty or wiped)');
break;
}
const Database = requireBetterSqlite();
const db = new Database(DATABASE_FILENAME, { readonly: true, fileMustExist: true });
try {
const tables = db
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
.all();
if (tables.length === 0) {
console.log('📊 No tables found (database is empty or wiped)');
break;
}
console.log('📊 Database Tables (row counts):\n');
console.log('Table Name | Row Count');
console.log('------------------------------------|----------');
for (const { name } of tables) {
// SQLite doesn't keep approximate row stats; use exact COUNT(*) here.
const { c } = db.prepare(`SELECT COUNT(*) AS c FROM "${name}"`).get();
const padded = name.padEnd(35);
console.log(`${padded} | ${c}`);
}
} finally {
db.close();
}
break;
}
default:
console.error('Error: Unknown command');
console.error('Usage: node db-sqlite.js <start|stop|snapshot|restore|wipe|check> [name]');
process.exit(1);
}
+264
View File
@@ -0,0 +1,264 @@
#!/usr/bin/env node
const { spawnSync } = require('child_process');
const { runCompose, runContainer } = require('./compose');
const COMPOSE_PROJECT_NAME = process.env.COMPOSE_PROJECT_NAME || 'strapi_complex';
function getComposeEnv() {
return { ...process.env, COMPOSE_PROJECT_NAME };
}
function getContainerId(composeFile, cwd, serviceName) {
// Try compose ps -q first (works with docker-compose and docker compose v2).
try {
const output = runCompose(['-f', composeFile, 'ps', '-q', serviceName], {
cwd,
env: getComposeEnv(),
}).trim();
if (output) return output.split('\n')[0];
} catch (error) {
// Fall through to runtime-level lookup — podman-compose doesn't support `-q`.
}
// Fallback: use `<runtime> ps --filter name=<project>_<service>` via the container CLI.
// docker-compose v1 names containers `<project>_<service>_<index>`; v2 uses
// `<project>-<service>-<index>`. We filter on both separators to be safe.
try {
const underscored = runContainer([
'ps',
'-a',
'--filter',
`name=${COMPOSE_PROJECT_NAME}_${serviceName}`,
'--format',
'{{.ID}}',
]).trim();
if (underscored) return underscored.split('\n')[0];
const hyphenated = runContainer([
'ps',
'-a',
'--filter',
`name=${COMPOSE_PROJECT_NAME}-${serviceName}`,
'--format',
'{{.ID}}',
]).trim();
if (hyphenated) return hyphenated.split('\n')[0];
} catch (error) {
// Swallow — caller treats null as "not running".
}
return null;
}
function getContainerName(composeFile, cwd, serviceName) {
const containerId = getContainerId(composeFile, cwd, serviceName);
if (!containerId) return null;
try {
const nameOutput = runContainer(['inspect', '--format={{.Name}}', containerId]).trim();
return nameOutput.replace(/^\//, '');
} catch (error) {
return null;
}
}
function isContainerRunning(containerId) {
if (!containerId) return false;
try {
const status = runContainer(['inspect', '--format={{.State.Running}}', containerId]).trim();
return status === 'true';
} catch (error) {
return false;
}
}
function startContainer(composeFile, cwd, serviceName) {
runCompose(['-f', composeFile, 'up', '-d', serviceName], {
cwd,
stdio: 'inherit',
env: getComposeEnv(),
});
}
function waitForPostgresReady(containerId, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = spawnSync(
require('./compose').getContainerCommand(),
['exec', containerId, 'pg_isready', '-U', 'strapi'],
{ stdio: 'ignore' }
);
if (result.status === 0) return true;
}
return false;
}
function waitForMysqlReady(containerId, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const result = spawnSync(
require('./compose').getContainerCommand(),
[
'exec',
containerId,
'mysqladmin',
'ping',
'-h',
'127.0.0.1',
'-u',
'strapi',
'-pstrapi',
'--silent',
],
{ stdio: 'ignore' }
);
if (result.status === 0) return true;
}
return false;
}
function assertPostgresReady(containerId) {
if (!containerId) {
throw new Error('Postgres container not found. Start it and try again.');
}
try {
runContainer(['exec', containerId, 'pg_isready', '-U', 'strapi'], { stdio: 'ignore' });
} catch (error) {
throw new Error('Postgres is not ready yet. Wait for it to be ready and retry.');
}
}
function assertMysqlReady(containerId) {
if (!containerId) {
throw new Error('MySQL container not found. Start it and try again.');
}
try {
runContainer(
[
'exec',
containerId,
'mysqladmin',
'ping',
'-h',
'127.0.0.1',
'-u',
'strapi',
'-pstrapi',
'--silent',
],
{ stdio: 'ignore' }
);
} catch (error) {
throw new Error('MySQL is not ready yet. Wait for it to be ready and retry.');
}
}
function assertMariadbReady(containerId) {
if (!containerId) {
throw new Error('MariaDB container not found. Start it and try again.');
}
try {
// mariadb ships `mariadb-admin` (preferred) and keeps `mysqladmin` as a legacy alias.
// Force TCP via -h 127.0.0.1 — the default socket path isn't populated in these images.
runContainer(
[
'exec',
containerId,
'mariadb-admin',
'ping',
'-h',
'127.0.0.1',
'-u',
'strapi',
'-pstrapi',
'--silent',
],
{ stdio: 'ignore' }
);
} catch (error) {
// Fall back to the mysqladmin alias for older mariadb images.
try {
runContainer(
[
'exec',
containerId,
'mysqladmin',
'ping',
'-h',
'127.0.0.1',
'-u',
'strapi',
'-pstrapi',
'--silent',
],
{ stdio: 'ignore' }
);
} catch (innerError) {
throw new Error('MariaDB is not ready yet. Wait for it to be ready and retry.');
}
}
}
function waitForMariadbReady(containerId, timeoutMs = 30000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
assertMariadbReady(containerId);
return true;
} catch (error) {
// wait and retry
}
}
return false;
}
function getDatabaseEnv(dbType) {
const env = { ...process.env };
if (!dbType || !['postgres', 'mysql', 'mariadb', 'sqlite'].includes(dbType)) {
throw new Error(`Unsupported database type: ${dbType}`);
}
if (dbType === 'sqlite') {
env.DATABASE_CLIENT = 'sqlite';
env.DATABASE_FILENAME = env.DATABASE_FILENAME || '.tmp/data.db';
return env;
}
const postgresPort = env.POSTGRES_PORT || '5432';
const mysqlPort = env.MYSQL_PORT || '3306';
const mariadbPort = env.MARIADB_PORT || '3307';
const portByDialect = {
postgres: postgresPort,
mysql: mysqlPort,
mariadb: mariadbPort,
};
const databasePort = env.DATABASE_PORT || portByDialect[dbType];
// MariaDB uses the `mysql` knex client (wire-compatible) — connection-time detail.
env.DATABASE_CLIENT = dbType === 'postgres' ? 'postgres' : 'mysql';
env.DATABASE_HOST = env.DATABASE_HOST || 'localhost';
env.DATABASE_PORT = databasePort;
env.DATABASE_NAME = env.DATABASE_NAME || 'strapi';
env.DATABASE_USERNAME = env.DATABASE_USERNAME || 'strapi';
env.DATABASE_PASSWORD = env.DATABASE_PASSWORD || 'strapi';
env.DATABASE_SSL = env.DATABASE_SSL || 'false';
return env;
}
module.exports = {
COMPOSE_PROJECT_NAME,
getComposeEnv,
getContainerId,
getContainerName,
isContainerRunning,
startContainer,
waitForPostgresReady,
waitForMysqlReady,
waitForMariadbReady,
assertPostgresReady,
assertMysqlReady,
assertMariadbReady,
getDatabaseEnv,
};
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const {
getContainerId,
isContainerRunning,
startContainer,
waitForPostgresReady,
waitForMysqlReady,
waitForMariadbReady,
getDatabaseEnv,
} = require('./db-utils');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const DOCKER_COMPOSE_FILE = path.join(COMPLEX_DIR, 'docker-compose.dev.yml');
const UPLOADS_DIR = path.join(COMPLEX_DIR, 'public', 'uploads');
const dbType = process.argv[2];
if (!dbType || !['postgres', 'mysql', 'mariadb', 'sqlite'].includes(dbType)) {
console.error('Error: Database type is required');
console.error('Usage: node develop-with-db.js <postgres|mysql|mariadb|sqlite>');
process.exit(1);
}
function ensureUploadsDir() {
if (!fs.existsSync(UPLOADS_DIR)) {
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
}
}
// Per-dialect readiness check map
const readinessCheckers = {
postgres: waitForPostgresReady,
mysql: waitForMysqlReady,
mariadb: waitForMariadbReady,
};
// Start container if not running (no-op for sqlite)
function ensureContainerRunning(serviceName) {
if (dbType === 'sqlite') return;
const containerId = getContainerId(DOCKER_COMPOSE_FILE, COMPLEX_DIR, serviceName);
const waitForReady = readinessCheckers[dbType];
if (containerId && isContainerRunning(containerId)) {
console.log(`${serviceName} container is already running`);
console.log('Waiting for database to be ready...');
waitForReady(containerId);
return;
}
console.log(`Starting ${serviceName} container...`);
try {
startContainer(DOCKER_COMPOSE_FILE, COMPLEX_DIR, serviceName);
console.log(`${serviceName} container started`);
console.log('Waiting for database to be ready...');
waitForReady(getContainerId(DOCKER_COMPOSE_FILE, COMPLEX_DIR, serviceName));
} catch (error) {
console.error(`Error starting ${serviceName} container:`, error.message);
process.exit(1);
}
}
// Start Strapi develop
function startStrapi() {
// For containerized dialects the service name equals the dbType.
if (['postgres', 'mysql', 'mariadb'].includes(dbType)) {
ensureContainerRunning(dbType);
}
const env = getDatabaseEnv(dbType);
ensureUploadsDir();
console.log(`\n🚀 Starting Strapi with ${dbType} database...\n`);
const isWindows = process.platform === 'win32';
const strapiProcess = spawn(isWindows ? 'yarn.cmd' : 'yarn', ['develop'], {
cwd: COMPLEX_DIR,
env,
stdio: 'inherit',
shell: !isWindows,
});
let isShuttingDown = false;
const cleanup = () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('\n\n⏹️ Stopping Strapi server (database container, if any, keeps running)...');
strapiProcess.kill('SIGINT');
strapiProcess.on('exit', () => {
process.exit(0);
});
setTimeout(() => {
if (!strapiProcess.killed) {
strapiProcess.kill('SIGKILL');
process.exit(0);
}
}, 5000);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
strapiProcess.on('exit', (code) => {
if (!isShuttingDown) {
process.exit(code || 0);
}
});
strapiProcess.on('error', (error) => {
console.error('Error starting Strapi:', error);
process.exit(1);
});
}
startStrapi();
File diff suppressed because it is too large Load Diff
+530
View File
@@ -0,0 +1,530 @@
#!/usr/bin/env node
const fs = require('fs');
const os = require('os');
const path = require('path');
const { createStrapi, compileStrapi } = require('@strapi/strapi');
let strapi;
const BASE_COUNTS = {
basic: 5,
basicDp: { published: 3, drafts: 2 },
basicDpI18n: { published: 3, drafts: 2 },
relation: 5,
relationDp: { published: 5, drafts: 3 },
relationDpI18n: { published: 5, drafts: 3 },
mediaFiles: 10,
};
const LOCALES = ['en', 'fr'];
function parseCliArgs(argv) {
const opts = { multiplier: 1 };
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--multiplier' && argv[i + 1] != null) {
opts.multiplier = Number(argv[i + 1]);
i += 1;
continue;
}
if (arg && arg.startsWith('--multiplier=')) {
opts.multiplier = Number(arg.split('=')[1]);
continue;
}
if (!Number.isNaN(Number(arg))) {
opts.multiplier = Number(arg);
}
}
const envMultiplier = process.env.SEED_MULTIPLIER;
if (!Number.isNaN(Number(envMultiplier))) {
opts.multiplier = Number(envMultiplier);
}
if (!Number.isFinite(opts.multiplier) || opts.multiplier <= 0) {
opts.multiplier = 1;
}
return opts;
}
function applyMultiplierToCounts(base, multiplier) {
const m = Number(multiplier) || 1;
return {
basic: base.basic * m,
basicDp: {
published: base.basicDp.published * m,
drafts: base.basicDp.drafts * m,
},
basicDpI18n: {
published: base.basicDpI18n.published * m,
drafts: base.basicDpI18n.drafts * m,
},
relation: base.relation * m,
relationDp: {
published: base.relationDp.published * m,
drafts: base.relationDp.drafts * m,
},
relationDpI18n: {
published: base.relationDpI18n.published * m,
drafts: base.relationDpI18n.drafts * m,
},
mediaFiles: base.mediaFiles * m,
};
}
const { multiplier } = parseCliArgs(process.argv.slice(2));
const COUNTS = applyMultiplierToCounts(BASE_COUNTS, multiplier);
const random = {
string: (len = 8) =>
Math.random()
.toString(36)
.substring(2, len + 2),
number: (min = 0, max = 100) => Math.floor(Math.random() * (max - min + 1)) + min,
boolean: () => Math.random() > 0.5,
date: () => new Date(2020 + Math.random() * 5, random.number(0, 11), random.number(1, 28)),
pick: (arr) => arr[random.number(0, arr.length - 1)],
};
const fields = {
basic: () => ({
stringField: `String ${random.string()}`,
textField: `Text ${random.string(20)}`,
richText: `<p>Rich ${random.string(15)}</p>`,
integerField: random.number(1, 1000),
bigintegerField: random.number(1000000, 9999999),
decimalField: parseFloat((Math.random() * 100).toFixed(2)),
floatField: parseFloat((Math.random() * 100).toFixed(2)),
booleanField: random.boolean(),
dateField: random.date().toISOString().split('T')[0],
datetimeField: random.date().toISOString(),
timeField: `${String(random.number(0, 23)).padStart(2, '0')}:${String(random.number(0, 59)).padStart(2, '0')}:00`,
emailField: `seed${random.string()}@example.com`,
passwordField: 'TestPassword123!',
jsonField: { key: random.string(), value: random.number() },
enumerationField: random.pick(['one', 'two', 'three']),
}),
};
const components = {
textBlock: () => ({
heading: `Heading ${random.string()}`,
body: `<p>Body ${random.string(20)}</p>`,
author: `Author ${random.string(5)}`,
publishedDate: random.date().toISOString().split('T')[0],
}),
mediaBlock: () => ({
title: `Media ${random.string()}`,
mediaUrl: `https://example.com/media/${random.string()}.${random.pick(['jpg', 'mp4', 'mp3'])}`,
mediaType: random.pick(['image', 'video', 'audio']),
description: `Description ${random.string(12)}`,
}),
simpleInfo: () => ({
title: `Info ${random.string()}`,
description: `Description ${random.string(10)}`,
count: random.number(1, 100),
active: random.boolean(),
}),
imageBlock: () => ({
alt: `Image ${random.string()}`,
url: `https://example.com/images/${random.string()}.jpg`,
caption: `Caption ${random.string()}`,
width: random.number(100, 2000),
height: random.number(100, 2000),
}),
logo: (mediaId) => ({
name: `Logo ${random.string()}`,
logo: mediaId || null,
}),
header: (mediaId) => ({
title: `Header ${random.string()}`,
headerlogo: components.logo(mediaId),
}),
referenceList: () => ({
title: `Reference List ${random.string()}`,
references: [{ label: `Ref ${random.string()}` }, { label: `Ref ${random.string()}` }],
}),
dz: (type, data) => ({ __component: `shared.${type}`, ...data }),
};
const PNG_BUFFER = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
]);
async function createMediaFile(index) {
const name = `v5-seed-${index}.png`;
const tempPath = path.join(os.tmpdir(), name);
try {
fs.writeFileSync(tempPath, PNG_BUFFER);
const result = await strapi
.plugin('upload')
.service('upload')
.upload({
files: {
filepath: tempPath,
path: tempPath,
originalFilename: name,
name,
size: PNG_BUFFER.length,
mimetype: 'image/png',
type: 'image/png',
},
data: {
fileInfo: {
alternativeText: `Seed ${name}`,
caption: name,
name: name.replace('.png', ''),
},
},
});
return result[0] || null;
} finally {
try {
fs.unlinkSync(tempPath);
} catch {
// no-op
}
}
}
async function createMediaFiles() {
console.log(`Creating ${COUNTS.mediaFiles} media files...`);
const files = [];
for (let i = 0; i < COUNTS.mediaFiles; i += 1) {
const file = await createMediaFile(i + 1);
if (file) files.push(file);
if ((i + 1) % 50 === 0 || i + 1 === COUNTS.mediaFiles) {
console.log(` media: ${i + 1}/${COUNTS.mediaFiles}`);
}
}
return files;
}
async function createDocument(uid, data, options = {}) {
const { status, locale } = options;
const payload = { data };
if (status) payload.status = status;
if (locale) payload.locale = locale;
return strapi.documents(uid).create(payload);
}
async function seedBasic() {
const items = [];
console.log(`Seeding basic: ${COUNTS.basic}`);
for (let i = 0; i < COUNTS.basic; i += 1) {
const entry = await createDocument('api::basic.basic', {
...fields.basic(),
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
});
items.push(entry);
}
return items;
}
async function seedBasicDp(mediaFiles) {
const published = [];
const drafts = [];
console.log(
`Seeding basic-dp: ${COUNTS.basicDp.published} published / ${COUNTS.basicDp.drafts} drafts`
);
for (let i = 0; i < COUNTS.basicDp.published; i += 1) {
const mediaId = mediaFiles[i % mediaFiles.length]?.id;
const entry = await createDocument(
'api::basic-dp.basic-dp',
{
...fields.basic(),
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
header: components.header(mediaId),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
components.dz('header', components.header(mediaId)),
],
},
{ status: 'published' }
);
published.push(entry);
}
for (let i = 0; i < COUNTS.basicDp.drafts; i += 1) {
const mediaId = mediaFiles[(i + 7) % mediaFiles.length]?.id;
const entry = await createDocument('api::basic-dp.basic-dp', {
...fields.basic(),
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
header: components.header(mediaId),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
components.dz('header', components.header(mediaId)),
],
});
drafts.push(entry);
}
return { published, drafts, all: [...published, ...drafts] };
}
async function seedBasicDpI18n() {
const published = [];
const drafts = [];
console.log(
`Seeding basic-dp-i18n: ${COUNTS.basicDpI18n.published} published / ${COUNTS.basicDpI18n.drafts} drafts per locale`
);
for (const locale of LOCALES) {
for (let i = 0; i < COUNTS.basicDpI18n.published; i += 1) {
const entry = await createDocument(
'api::basic-dp-i18n.basic-dp-i18n',
{
...fields.basic(),
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
},
{ status: 'published', locale }
);
published.push(entry);
}
for (let i = 0; i < COUNTS.basicDpI18n.drafts; i += 1) {
const entry = await createDocument(
'api::basic-dp-i18n.basic-dp-i18n',
{
...fields.basic(),
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
},
{ locale }
);
drafts.push(entry);
}
}
return { published, drafts, all: [...published, ...drafts] };
}
async function seedRelation() {
const items = [];
console.log(`Seeding relation: ${COUNTS.relation}`);
for (let i = 0; i < COUNTS.relation; i += 1) {
const entry = await createDocument('api::relation.relation', {
name: `Relation ${random.string()}`,
simpleInfo: components.simpleInfo(),
content: [
components.dz('simple-info', components.simpleInfo()),
components.dz('image-block', components.imageBlock()),
],
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
});
items.push(entry);
}
return items;
}
async function seedRelationDp(mediaFiles) {
const published = [];
const drafts = [];
console.log(
`Seeding relation-dp: ${COUNTS.relationDp.published} published / ${COUNTS.relationDp.drafts} drafts`
);
for (let i = 0; i < COUNTS.relationDp.published; i += 1) {
const mediaId = mediaFiles[i % mediaFiles.length]?.id;
const entry = await createDocument(
'api::relation-dp.relation-dp',
{
name: `Relation DP Published ${i + 1}`,
cover: mediaId || null,
simpleInfo: components.simpleInfo(),
content: [
components.dz('simple-info', components.simpleInfo()),
components.dz('image-block', components.imageBlock()),
],
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
header: components.header(mediaId),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
components.dz('header', components.header(mediaId)),
components.dz('reference-list', components.referenceList()),
],
},
{ status: 'published' }
);
published.push(entry);
}
for (let i = 0; i < COUNTS.relationDp.drafts; i += 1) {
const mediaId = mediaFiles[(i + 11) % mediaFiles.length]?.id;
const entry = await createDocument('api::relation-dp.relation-dp', {
name: `Relation DP Draft ${i + 1}`,
cover: mediaId || null,
simpleInfo: components.simpleInfo(),
content: [
components.dz('simple-info', components.simpleInfo()),
components.dz('image-block', components.imageBlock()),
],
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
header: components.header(mediaId),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
components.dz('header', components.header(mediaId)),
components.dz('reference-list', components.referenceList()),
],
});
drafts.push(entry);
}
return { published, drafts, all: [...published, ...drafts] };
}
async function seedRelationDpI18n() {
const published = [];
const drafts = [];
console.log(
`Seeding relation-dp-i18n: ${COUNTS.relationDpI18n.published} published / ${COUNTS.relationDpI18n.drafts} drafts per locale`
);
for (const locale of LOCALES) {
for (let i = 0; i < COUNTS.relationDpI18n.published; i += 1) {
const entry = await createDocument(
'api::relation-dp-i18n.relation-dp-i18n',
{
name: `Relation DP i18n Published ${locale}-${i + 1}`,
simpleInfo: components.simpleInfo(),
content: [
components.dz('simple-info', components.simpleInfo()),
components.dz('image-block', components.imageBlock()),
],
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
},
{ status: 'published', locale }
);
published.push(entry);
}
for (let i = 0; i < COUNTS.relationDpI18n.drafts; i += 1) {
const entry = await createDocument(
'api::relation-dp-i18n.relation-dp-i18n',
{
name: `Relation DP i18n Draft ${locale}-${i + 1}`,
simpleInfo: components.simpleInfo(),
content: [
components.dz('simple-info', components.simpleInfo()),
components.dz('image-block', components.imageBlock()),
],
textBlocks: [components.textBlock(), components.textBlock()],
mediaBlock: components.mediaBlock(),
sections: [
components.dz('text-block', components.textBlock()),
components.dz('media-block', components.mediaBlock()),
],
},
{ locale }
);
drafts.push(entry);
}
}
return { published, drafts, all: [...published, ...drafts] };
}
async function seed() {
console.log('🌱 Starting v5 seed...');
console.log(`Multiplier: ${multiplier}`);
console.log(`Counts: ${JSON.stringify(COUNTS)}\n`);
try {
const appContext = await compileStrapi();
strapi = await createStrapi(appContext).load();
strapi.log.level = 'error';
const mediaFiles = await createMediaFiles();
const basic = await seedBasic();
const basicDp = await seedBasicDp(mediaFiles);
const basicDpI18n = await seedBasicDpI18n();
const relation = await seedRelation();
const relationDp = await seedRelationDp(mediaFiles);
const relationDpI18n = await seedRelationDpI18n();
console.log('\n✅ v5 seed completed successfully');
console.log(` - basic: ${basic.length}`);
console.log(` - basic-dp: ${basicDp.all.length}`);
console.log(` - basic-dp-i18n: ${basicDpI18n.all.length}`);
console.log(` - relation: ${relation.length}`);
console.log(` - relation-dp: ${relationDp.all.length}`);
console.log(` - relation-dp-i18n: ${relationDpI18n.all.length}`);
console.log(` - upload files: ${mediaFiles.length}`);
} catch (error) {
console.error('\n❌ v5 seed failed:', error.message);
if (error.details?.errors) {
error.details.errors.forEach((e, index) => {
console.error(` ${index + 1}. ${e.path || 'unknown'}: ${e.message}`);
});
}
throw error;
} finally {
// We intentionally do not call strapi.destroy() here.
// In this script we rely on process exit, which avoids intermittent pool abort errors.
}
}
if (require.main === module) {
seed()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}
module.exports = seed;
+598
View File
@@ -0,0 +1,598 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const SCRIPT_DIR = __dirname;
const COMPLEX_DIR = path.resolve(SCRIPT_DIR, '..');
const MONOREPO_ROOT = path.resolve(COMPLEX_DIR, '../..');
// Create the v4 project. By default this will be created just outside the
// monorepo root (sibling to the repo) for isolated testing. You can still
// override the location with `V4_OUTSIDE_DIR` (absolute or relative).
const DEFAULT_OUTSIDE_DIR = path.resolve(MONOREPO_ROOT, '..', path.basename(COMPLEX_DIR) + '-v4');
const V4_PROJECT_DIR = process.env.V4_OUTSIDE_DIR
? path.resolve(process.cwd(), process.env.V4_OUTSIDE_DIR)
: DEFAULT_OUTSIDE_DIR;
// From v4 project, find docker-compose file (prefer the complex example's local compose file)
function findDockerComposeFile(v4ProjectDir) {
// Prefer the complex example's compose file in this monorepo
const complexDockerCompose = path.resolve(COMPLEX_DIR, 'docker-compose.dev.yml');
if (fs.existsSync(complexDockerCompose)) {
return complexDockerCompose;
}
// Fallback: try v4 project directory
const currentDockerCompose = path.resolve(v4ProjectDir, 'docker-compose.dev.yml');
if (fs.existsSync(currentDockerCompose)) {
return currentDockerCompose;
}
// Default to complex example location even if missing to keep paths stable
return complexDockerCompose;
}
const CONTENT_TYPES = [
'basic',
'basic-dp',
'basic-dp-i18n',
'relation',
'relation-dp',
'relation-dp-i18n',
// Anti-pattern stress schemas — added to exercise specific v4→v5 migration
// code paths that the baseline 6 types don't reach.
'hc-m2m-source',
'hc-m2m-target',
];
console.log('Setting up Strapi v4 project at:', V4_PROJECT_DIR);
console.log('⚠️ Note: This will overwrite existing files in the v4 project.\n');
// Ensure the v4 project directory exists
if (!fs.existsSync(V4_PROJECT_DIR)) {
fs.mkdirSync(V4_PROJECT_DIR, { recursive: true });
console.log('Created v4 project directory');
}
// Write package.json (always overwrite completely)
const packageJson = {
name: 'complex-v4',
version: '0.0.0',
private: true,
description: 'A Strapi v4 application with complex schemas',
scripts: {
build: 'strapi build',
console: 'strapi console',
deploy: 'strapi deploy',
dev: 'strapi develop',
develop: 'strapi develop',
start: 'strapi start',
strapi: 'strapi',
upgrade: 'npx @strapi/upgrade latest',
'upgrade:dry': 'npx @strapi/upgrade latest --dry',
'develop:postgres': 'node scripts/develop-with-db.js postgres',
'develop:mysql': 'node scripts/develop-with-db.js mysql',
'develop:mariadb': 'node scripts/develop-with-db.js mariadb',
'develop:sqlite': 'node scripts/develop-with-db.js sqlite',
// Run the simple seeder directly (no DB wrapper)
seed: 'node scripts/seed.js',
// Wrapper commands that will start DB containers when needed
'seed:postgres': 'node scripts/seed-with-db.js postgres',
'seed:mysql': 'node scripts/seed-with-db.js mysql',
'seed:mariadb': 'node scripts/seed-with-db.js mariadb',
'seed:sqlite': 'node scripts/seed-with-db.js sqlite',
},
dependencies: {
'@strapi/plugin-i18n': '4.26.0',
'@strapi/plugin-users-permissions': '4.26.0',
'@strapi/strapi': '4.26.0',
'better-sqlite3': '9.6.0',
entities: '2.2.0',
mysql2: '3.20.0',
pg: '8.20.0',
react: '^18.0.0',
'react-dom': '^18.0.0',
'react-is': '^18.0.0',
'react-router-dom': '5.3.4',
'styled-components': '5.3.3',
},
devDependencies: {
'@types/react': '^18.0.0',
'@types/react-dom': '^18.0.0',
},
engines: {
node: '>=18.0.0 <=20.x.x',
npm: '>=6.0.0',
},
strapi: {
uuid: 'complex-v4',
},
isStrapiMonorepo: false,
};
fs.writeFileSync(
path.join(V4_PROJECT_DIR, 'package.json'),
JSON.stringify(packageJson, null, 2) + '\n'
);
console.log('✅ Created/updated package.json');
// Create config directory
const configDir = path.join(V4_PROJECT_DIR, 'config');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}
// Write database.js — handles postgres, mysql, mariadb, sqlite.
// Note: mariadb uses the mysql2 driver (wire-compatible); DATABASE_PORT differs (3307 vs 3306).
const databaseConfig = `'use strict';
const path = require('path');
module.exports = ({ env }) => {
const client = env('DATABASE_CLIENT', 'postgres');
if (client === 'sqlite') {
return {
connection: {
client: 'better-sqlite3',
connection: {
filename: path.resolve(
__dirname,
'..',
env('DATABASE_FILENAME', '.tmp/data.db')
),
},
useNullAsDefault: true,
},
};
}
if (client === 'postgres') {
return {
connection: {
client: 'postgres',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 5432),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false)
? { rejectUnauthorized: false }
: false,
},
},
};
}
// mysql + mariadb — both use mysql2 driver.
return {
connection: {
client: 'mysql2',
connection: {
host: env('DATABASE_HOST', 'localhost'),
port: env.int('DATABASE_PORT', 3306),
database: env('DATABASE_NAME', 'strapi'),
user: env('DATABASE_USERNAME', 'strapi'),
password: env('DATABASE_PASSWORD', 'strapi'),
ssl: env.bool('DATABASE_SSL', false) || undefined,
},
},
};
};
`;
fs.writeFileSync(path.join(configDir, 'database.js'), databaseConfig);
// Write plugins.js with i18n support
const pluginsConfig = `'use strict';
module.exports = {
i18n: {
enabled: true,
config: {
defaultLocale: 'en',
locales: ['en'],
},
},
};
`;
fs.writeFileSync(path.join(configDir, 'plugins.js'), pluginsConfig);
// Write server.js
const serverConfig = `'use strict';
module.exports = ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
webhooks: {
populateRelations: env.bool('WEBHOOKS_POPULATE_RELATIONS', false),
},
});
`;
fs.writeFileSync(path.join(configDir, 'server.js'), serverConfig);
// Write admin.js
const adminConfig = `'use strict';
module.exports = ({ env }) => ({
auth: {
secret: env('ADMIN_JWT_SECRET'),
},
apiToken: {
salt: env('API_TOKEN_SALT'),
},
transfer: {
token: {
salt: env('TRANSFER_TOKEN_SALT'),
},
},
});
`;
fs.writeFileSync(path.join(configDir, 'admin.js'), adminConfig);
// Write api.js
const apiConfig = `'use strict';
module.exports = {
rest: {
defaultLimit: 25,
maxLimit: 100,
withCount: true,
},
};
`;
fs.writeFileSync(path.join(configDir, 'api.js'), apiConfig);
// Write middlewares.js
const middlewaresConfig = `'use strict';
module.exports = [
'strapi::errors',
{
name: 'strapi::security',
config: {
contentSecurityPolicy: {
useDefaults: true,
directives: {
'connect-src': ["'self'", 'https:'],
'img-src': [
"'self'",
'data:',
'blob:',
'dl.airtable.com',
'market-assets.strapi.io',
],
'media-src': [
"'self'",
'data:',
'blob:',
'dl.airtable.com',
'market-assets.strapi.io',
],
upgradeInsecureRequests: null,
},
},
},
},
'strapi::cors',
'strapi::poweredBy',
'strapi::logger',
'strapi::query',
'strapi::body',
'strapi::session',
'strapi::favicon',
'strapi::public',
];
`;
fs.writeFileSync(path.join(configDir, 'middlewares.js'), middlewaresConfig);
// Create src directory structure
const srcDir = path.join(V4_PROJECT_DIR, 'src');
if (!fs.existsSync(srcDir)) {
fs.mkdirSync(srcDir, { recursive: true });
}
// Write src/index.js
const indexJs = `'use strict';
module.exports = {
/**
* An asynchronous register function that runs before
* your application is initialized.
*
* This gives you an opportunity to extend code.
*/
register(/*{ strapi }*/) {},
/**
* An asynchronous bootstrap function that runs before
* your application gets started.
*
* This gives you an opportunity to set up your data model,
* run jobs, or perform some special logic.
*/
bootstrap(/*{ strapi }*/) {},
};
`;
fs.writeFileSync(path.join(srcDir, 'index.js'), indexJs);
// Write src/admin/app.js
const adminAppJs = `const config = {
locales: [],
};
const bootstrap = (app) => {
console.log(app);
};
module.exports = {
config,
bootstrap,
};
`;
const adminDir = path.join(srcDir, 'admin');
if (!fs.existsSync(adminDir)) {
fs.mkdirSync(adminDir, { recursive: true });
}
fs.writeFileSync(path.join(adminDir, 'app.js'), adminAppJs);
// Copy content types
const apiDir = path.join(srcDir, 'api');
if (!fs.existsSync(apiDir)) {
fs.mkdirSync(apiDir, { recursive: true });
}
CONTENT_TYPES.forEach((contentType) => {
const contentTypeDir = path.join(apiDir, contentType);
if (!fs.existsSync(contentTypeDir)) {
fs.mkdirSync(contentTypeDir, { recursive: true });
}
// Copy schema (remove polymorphic relations for v4 compatibility)
const schemaSource = path.join(
COMPLEX_DIR,
'src',
'api',
contentType,
'content-types',
contentType,
'schema.json'
);
const schemaDest = path.join(contentTypeDir, 'content-types', contentType, 'schema.json');
if (fs.existsSync(schemaSource)) {
const schemaDir = path.dirname(schemaDest);
if (!fs.existsSync(schemaDir)) {
fs.mkdirSync(schemaDir, { recursive: true });
}
// Read and modify schema for v4 compatibility
const schema = JSON.parse(fs.readFileSync(schemaSource, 'utf8'));
fs.writeFileSync(schemaDest, JSON.stringify(schema, null, 2) + '\n');
console.log(`Copied schema for ${contentType}`);
}
// Create controller
const controllerJs = `'use strict';
/**
* ${contentType} controller
*/
const { createCoreController } = require('@strapi/strapi').factories;
module.exports = createCoreController('api::${contentType}.${contentType}');
`;
const controllersDir = path.join(contentTypeDir, 'controllers');
if (!fs.existsSync(controllersDir)) {
fs.mkdirSync(controllersDir, { recursive: true });
}
fs.writeFileSync(path.join(controllersDir, `${contentType}.js`), controllerJs);
// Create routes
const routesJs = `'use strict';
/**
* ${contentType} router
*/
const { createCoreRouter } = require('@strapi/strapi').factories;
module.exports = createCoreRouter('api::${contentType}.${contentType}');
`;
const routesDir = path.join(contentTypeDir, 'routes');
if (!fs.existsSync(routesDir)) {
fs.mkdirSync(routesDir, { recursive: true });
}
fs.writeFileSync(path.join(routesDir, `${contentType}.js`), routesJs);
// Create service
const serviceJs = `'use strict';
/**
* ${contentType} service
*/
const { createCoreService } = require('@strapi/strapi').factories;
module.exports = createCoreService('api::${contentType}.${contentType}');
`;
const servicesDir = path.join(contentTypeDir, 'services');
if (!fs.existsSync(servicesDir)) {
fs.mkdirSync(servicesDir, { recursive: true });
}
fs.writeFileSync(path.join(servicesDir, `${contentType}.js`), serviceJs);
});
// Copy components
const componentsSourceDir = path.join(COMPLEX_DIR, 'src', 'components');
const componentsDestDir = path.join(srcDir, 'components');
if (fs.existsSync(componentsSourceDir)) {
function copyRecursive(src, dest) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyRecursive(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
copyRecursive(componentsSourceDir, componentsDestDir);
console.log('Copied components');
}
// Create public directory structure
const publicDir = path.join(V4_PROJECT_DIR, 'public');
if (!fs.existsSync(publicDir)) {
fs.mkdirSync(publicDir, { recursive: true });
}
const publicUploadsDir = path.join(publicDir, 'uploads');
if (!fs.existsSync(publicUploadsDir)) {
fs.mkdirSync(publicUploadsDir, { recursive: true });
}
// Create .gitkeep in uploads to ensure directory exists
fs.writeFileSync(path.join(publicUploadsDir, '.gitkeep'), '');
// Create robots.txt
const robotsTxt = `User-agent: *
Disallow: /
`;
fs.writeFileSync(path.join(publicDir, 'robots.txt'), robotsTxt);
// Create .gitignore
const gitignore = `node_modules
.tmp
.cache
build
dist
.env
.env.local
.env.*.local
*.log
.DS_Store
`;
fs.writeFileSync(path.join(V4_PROJECT_DIR, '.gitignore'), gitignore);
// Create .env.example
const envExample = `HOST=0.0.0.0
PORT=1337
APP_KEYS=toBeModified1,toBeModified2,toBeModified3,toBeModified4
API_TOKEN_SALT=toBeModified
ADMIN_JWT_SECRET=toBeModified
TRANSFER_TOKEN_SALT=toBeModified
JWT_SECRET=toBeModified
# Database
DATABASE_CLIENT=postgres
# DATABASE_HOST=localhost
# DATABASE_PORT=5432
# DATABASE_NAME=strapi
# DATABASE_USERNAME=strapi
# DATABASE_PASSWORD=strapi
# DATABASE_SSL=false
# DATABASE_CLIENT=mysql
# DATABASE_PORT=3306
`;
fs.writeFileSync(path.join(V4_PROJECT_DIR, '.env.example'), envExample);
// Copy .env.example to .env (always overwrite)
const envPath = path.join(V4_PROJECT_DIR, '.env');
fs.copyFileSync(path.join(V4_PROJECT_DIR, '.env.example'), envPath);
console.log('✅ Created/updated .env file from .env.example');
// Create scripts directory
const v4ScriptsDir = path.join(V4_PROJECT_DIR, 'scripts');
if (!fs.existsSync(v4ScriptsDir)) {
fs.mkdirSync(v4ScriptsDir, { recursive: true });
}
// Write shared db-utils.js + compose.js for the v4 scripts.
// compose.js is a dep of db-utils.js (runtime auto-detection for docker/podman).
for (const name of ['db-utils.js', 'compose.js']) {
const src = path.join(SCRIPT_DIR, name);
const contents = fs.readFileSync(src, 'utf8');
fs.writeFileSync(path.join(v4ScriptsDir, name), contents);
try {
fs.chmodSync(path.join(v4ScriptsDir, name), 0o755);
} catch (error) {
// chmod might fail on Windows, that's okay
}
}
// Create develop-with-db.js script for v4 project
const dockerComposePath = findDockerComposeFile(V4_PROJECT_DIR);
const developTemplatePath = path.join(SCRIPT_DIR, 'v4', 'develop-with-db.js');
const developTemplate = fs.readFileSync(developTemplatePath, 'utf8');
const developWithDbScript = developTemplate.replace(
'__DOCKER_COMPOSE_FILE__',
dockerComposePath.replace(/\\/g, '/')
);
fs.writeFileSync(path.join(v4ScriptsDir, 'develop-with-db.js'), developWithDbScript);
// Make it executable
try {
fs.chmodSync(path.join(v4ScriptsDir, 'develop-with-db.js'), 0o755);
} catch (error) {
// chmod might fail on Windows, that's okay
}
console.log('✅ Created database development scripts');
// Copy seed script (always overwrite) - use the simpler seeder
const seedScriptSource = path.join(SCRIPT_DIR, 'seed-v4.js');
const seedScriptDest = path.join(v4ScriptsDir, 'seed.js');
fs.copyFileSync(seedScriptSource, seedScriptDest);
try {
fs.chmodSync(seedScriptDest, 0o755);
} catch (error) {
// chmod might fail on Windows, that's okay
}
console.log('✅ Created/updated seed script');
// Create seed-with-db.js wrapper script
const seedTemplatePath = path.join(SCRIPT_DIR, 'v4', 'seed-with-db.js');
const seedTemplate = fs.readFileSync(seedTemplatePath, 'utf8');
const seedWithDbScript = seedTemplate.replace(
'__DOCKER_COMPOSE_FILE__',
dockerComposePath.replace(/\\/g, '\\\\')
);
fs.writeFileSync(path.join(v4ScriptsDir, 'seed-with-db.js'), seedWithDbScript);
try {
fs.chmodSync(path.join(v4ScriptsDir, 'seed-with-db.js'), 0o755);
} catch (error) {
// chmod might fail on Windows, that's okay
}
console.log('✅ Created/updated seed-with-db.js wrapper script');
console.log('\n✅ V4 project structure created successfully!');
console.log(`\nProject location: ${V4_PROJECT_DIR}`);
console.log('\nNext steps:');
console.log(`1. cd ${V4_PROJECT_DIR}`);
console.log('2. yarn install (install dependencies)');
console.log('3. Edit .env file if needed (app keys will be auto-generated)');
console.log('4. yarn develop:postgres (or develop:mysql)');
@@ -0,0 +1,108 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const PROJECT_DIR = path.resolve(__dirname, '..');
const DOCKER_COMPOSE_FILE = '__DOCKER_COMPOSE_FILE__';
const {
getContainerId,
isContainerRunning,
startContainer,
assertPostgresReady,
assertMysqlReady,
assertMariadbReady,
getDatabaseEnv,
} = require('./db-utils');
const dbType = process.argv[2];
if (!dbType || !['postgres', 'mysql', 'mariadb', 'sqlite'].includes(dbType)) {
console.error('Error: Database type is required');
console.error('Usage: node scripts/develop-with-db.js <postgres|mysql|mariadb|sqlite>');
process.exit(1);
}
const readinessCheckers = {
postgres: assertPostgresReady,
mysql: assertMysqlReady,
mariadb: assertMariadbReady,
};
function ensureContainerRunning(serviceName) {
if (dbType === 'sqlite') return;
const assertReady = readinessCheckers[dbType];
const containerId = getContainerId(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
if (containerId && isContainerRunning(containerId)) {
console.log(`${serviceName} container is already running`);
assertReady(containerId);
return;
}
console.log(`Starting ${serviceName} container...`);
try {
startContainer(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
console.log(`${serviceName} container started`);
const newContainerId = getContainerId(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
assertReady(newContainerId);
} catch (error) {
console.error(`Error starting ${serviceName} container: ${error.message}`);
process.exit(1);
}
}
function startStrapi() {
if (['postgres', 'mysql', 'mariadb'].includes(dbType)) {
ensureContainerRunning(dbType);
}
const env = getDatabaseEnv(dbType);
console.log(`\n🚀 Starting Strapi with ${dbType} database...\n`);
const isWindows = process.platform === 'win32';
const strapiProcess = spawn(isWindows ? 'yarn.cmd' : 'yarn', ['develop'], {
cwd: PROJECT_DIR,
env,
stdio: 'inherit',
shell: !isWindows,
});
let isShuttingDown = false;
const cleanup = () => {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('\n\n⏹️ Stopping Strapi server (database container, if any, keeps running)...');
strapiProcess.kill('SIGINT');
strapiProcess.on('exit', () => {
process.exit(0);
});
setTimeout(() => {
if (!strapiProcess.killed) {
strapiProcess.kill('SIGKILL');
process.exit(0);
}
}, 5000);
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
strapiProcess.on('exit', (code) => {
if (!isShuttingDown) {
process.exit(code || 0);
}
});
strapiProcess.on('error', (error) => {
console.error('Error starting Strapi:', error);
process.exit(1);
});
}
startStrapi();
@@ -0,0 +1,87 @@
#!/usr/bin/env node
const { spawn } = require('child_process');
const path = require('path');
const PROJECT_DIR = path.resolve(__dirname, '..');
const DOCKER_COMPOSE_FILE = '__DOCKER_COMPOSE_FILE__';
const {
getContainerId,
isContainerRunning,
startContainer,
assertPostgresReady,
assertMysqlReady,
assertMariadbReady,
getDatabaseEnv,
} = require('./db-utils');
const dbType = process.argv[2];
let multiplierArgIndex = 3;
if (process.argv[multiplierArgIndex] === '--') {
multiplierArgIndex = 4;
}
const multiplier = process.argv[multiplierArgIndex] || '1';
if (!dbType || !['postgres', 'mysql', 'mariadb', 'sqlite'].includes(dbType)) {
console.error('Error: Database type is required');
console.error('Usage: node scripts/seed-with-db.js <postgres|mysql|mariadb|sqlite> [multiplier]');
process.exit(1);
}
const readinessCheckers = {
postgres: assertPostgresReady,
mysql: assertMysqlReady,
mariadb: assertMariadbReady,
};
function ensureContainerRunning(serviceName) {
if (dbType === 'sqlite') return;
const assertReady = readinessCheckers[dbType];
const containerId = getContainerId(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
if (containerId && isContainerRunning(containerId)) {
console.log(`${serviceName} container is already running`);
assertReady(containerId);
return;
}
console.log(`Starting ${serviceName} container...`);
try {
startContainer(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
console.log(`${serviceName} container started`);
const newContainerId = getContainerId(DOCKER_COMPOSE_FILE, PROJECT_DIR, serviceName);
assertReady(newContainerId);
} catch (error) {
console.error(`Error starting ${serviceName} container: ${error.message}`);
process.exit(1);
}
}
function runSeed() {
if (['postgres', 'mysql', 'mariadb'].includes(dbType)) {
ensureContainerRunning(dbType);
}
const env = getDatabaseEnv(dbType);
console.log(`\n🌱 Seeding database (${dbType}) with multiplier: ${multiplier}...\n`);
const isWindows = process.platform === 'win32';
const seedProcess = spawn(isWindows ? 'node.exe' : 'node', ['scripts/seed.js', multiplier], {
cwd: PROJECT_DIR,
env,
stdio: 'inherit',
shell: !isWindows,
});
seedProcess.on('exit', (code) => {
process.exit(code || 0);
});
seedProcess.on('error', (error) => {
console.error('Error running seed script:', error);
process.exit(1);
});
}
runSeed();
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
import type { StrapiApp } from '@strapi/strapi/admin';
export default {
config: {
locales: ['fr'],
},
bootstrap(app: StrapiApp) {
console.log(app);
},
};
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["../plugins/**/admin/src/**/*", "./"],
"exclude": ["node_modules/", "build/", "dist/", "**/*.test.ts"]
}
@@ -0,0 +1,179 @@
{
"kind": "collectionType",
"collectionName": "basic_dp_i18ns",
"info": {
"singularName": "basic-dp-i18n",
"pluralName": "basic-dp-i18ns",
"displayName": "Basic DP i18n"
},
"options": {
"draftAndPublish": true
},
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": {
"stringField": {
"type": "string",
"required": true,
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"textField": {
"type": "text",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"richText": {
"type": "richtext",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"integerField": {
"type": "integer",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"bigintegerField": {
"type": "biginteger",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"decimalField": {
"type": "decimal",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"floatField": {
"type": "float",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"booleanField": {
"type": "boolean",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"dateField": {
"type": "date",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"datetimeField": {
"type": "datetime",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"timeField": {
"type": "time",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"emailField": {
"type": "email",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"uidField": {
"type": "uid",
"targetField": "stringField",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"passwordField": {
"type": "password",
"pluginOptions": {
"i18n": {
"localized": false
}
}
},
"jsonField": {
"type": "json",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"enumerationField": {
"type": "enumeration",
"enum": ["one", "two", "three"],
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"textBlocks": {
"type": "component",
"repeatable": true,
"component": "shared.text-block",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"mediaBlock": {
"type": "component",
"repeatable": false,
"component": "shared.media-block",
"pluginOptions": {
"i18n": {
"localized": true
}
}
},
"sections": {
"type": "dynamiczone",
"components": ["shared.text-block", "shared.media-block"],
"pluginOptions": {
"i18n": {
"localized": true
}
}
}
}
}
@@ -0,0 +1,7 @@
/**
* basic-dp-i18n controller
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::basic-dp-i18n.basic-dp-i18n');

Some files were not shown because too many files have changed in this diff Show More