Easy CMS 1.13: built for a million documents
Six releases since 1.7 for collections of a million posts: cursors, Thai search with trigram indexes, HTTP caching, an outbox and search adapters.
Easy CMS 1.7 made it ready to run on several servers. The six releases since are about size: a collection of 1,000,000 documents. A load test at that size came first, to show where the time goes, and none of the releases breaks anything.
What a million posts looks like
A nightly load test fills Postgres 17 with 1,000,000 posts and sends 20 requests at once from a 4-core GitHub runner. On 1.10, the p95 times were:
A post by id or slug: 22 ms
The first page of a list: 28 ms
The next page by cursor, deep in the list: 29 ms
Thai
likesearch with a trigram index: 69 msSave a draft, with a version: 89 ms
Some things are still slow, and the Databases guide lists them too: a random page by number deep in the list takes seconds, and so do queries inside blocks.
Lists count less (1.8)
Counting a million rows for every page is the cost of totalDocs. count: false (REST ?count=false) skips it, for archives and infinite scroll. Counts of the same list are now reused for countCacheSeconds (5 by default), so a list many people open is counted once in that time.
Cursor pagination (1.9)
const page = await cms.find('posts', { cursor: '', limit: 20 })
const next = await cms.find('posts', { cursor: page.nextCursor, limit: 20 })Each page takes as long as the first, however deep. maxPage keeps API clients from paging deep by number. See find results.
Thai search with trigram indexes (1.10)
{ name: 'title', type: 'text', index: 'trigram' }On Postgres, a GIN pg_trgm index lets like find any part of a text without reading every row, in Thai as in English. At a million posts, a Thai search takes 69 ms (p95). See Search with like.
HTTP caching for a CDN (1.11)
httpCache: { sMaxAge: 60, staleWhileRevalidate: 600, purge: (tags) => tags.forEach((tag) => revalidateTag(tag)) }Public reads of published content get s-maxage, an ETag and cache tags, so a CDN can answer them. Saving, publishing or deleting purges just the tags that changed. Anything with a login or drafts stays no-store. See HTTP caching.
An outbox for after hooks (1.12)
outbox: trueHooks, webhooks and cache purges are written in the same transaction as the save, then run after it commits and retried until they succeed. A save that succeeds always gets its side effects. Failed entries show in Settings → Jobs → Outbox. Hooks must be safe to run twice. See Outbox.
Search adapters (1.13)
search: memorySearch(),
collections: [{ slug: 'posts', search: { fields: ['title', 'body'] }, /* … */ }],cms.search() and GET /api/cms/search find published documents across fields and collections, best match first, with the reader's access. memorySearch() splits Thai into words with Intl.Segmenter, so น้ำท่วม finds ข่าวน้ำท่วมกรุงเทพมหานคร. It suits development and small sites. Adapters for search engines implement SearchAdapter. See Search.
Upgrading
Upgrade every package together. All of this is off until you turn it on. Only outbox: true needs a migration:
npm install @easy-cms/core@latest easy-cms@latestThe changelog lists every release.