fix(core): add offset pagination to getEmDashCollection for numbered archives (#1364)#1577
Conversation
🦋 Changeset detectedLatest commit: 5464f24 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Could not push formatting changes to this fork. The contributor may have "Allow edits by maintainers" disabled. Please run the formatter locally: |
There was a problem hiding this comment.
Reviewed the PR adding offset pagination to getEmDashCollection and surfacing hasMore on CollectionResult.
What I checked:
- Traced the offset path through
loader.ts(rawOffset/offsetresolution, theLIMIT/OFFSETclause construction, and the SQLiteLIMIT -1 OFFSET Nvs Postgres standaloneOFFSET Nsplit) and throughquery.ts(over-fetchhasMoredetection,hasMoreResult,bucketFilterbypass,sliceCollectionResult, and the cache key). - Verified correctness across edge cases by hand: offset near the end, exactly-at-boundary row counts, empty page past the end,
offset: 0(treated as no-op), fractional/negative offsets (no-ops), cursor-wins-over-offset, and the bucketed+sliced small-limit path.hasMoreis correct in every case — including the new unconditionalhasMore: trueinsliceCollectionResult, which is safe because slicing only occurs whencached.entries.length > limit. - SQL safety:
offsetis interpolated via Kysely's tagged template, so it's parameterized (no injection). Field/identifier handling unchanged. - Conventions: locale filter still applies to the offset query (no new locale bug); no admin UI/Lingui/RTL surface; no route files changed so no query-count snapshot update is needed; changeset is present, leads with
Adds, describes the observable effect, and uses aminorbump appropriate for an additive feature; docs updated inapi.mdx.
Conclusion: The implementation is correct and well-tested. The loader tests cover skip/disjoint-pages/empty-page/cursor-wins, and the query tests cover offset forwarding, hasMore true/false/undefined, and cache-key distinctness. The only finding is a non-blocking suggestion: one test name states the opposite of what the code does.
| expect(result.hasMore).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("keeps offset out of the request-cache key so different pages don't collide", async () => { |
There was a problem hiding this comment.
[suggestion] The test name says it "keeps offset out of the request-cache key," but the implementation does the opposite: collectionCacheKey in query.ts adds filter.offset ?? "" to the key, and the changeset explicitly states "the offset is part of the request-cache key so distinct pages don't collide." The test body is correct (it asserts two distinct loader calls for offsets 0 and 20, proving the pages don't collide), but the name reads as the inverse of the intended behavior. A future maintainer taking the name literally could drop offset from the cache key and silently break numbered-archive pagination. Suggested rename to match what the test actually verifies:
| it("keeps offset out of the request-cache key so different pages don't collide", async () => { | |
| it("includes offset in the request-cache key so distinct pages don't collide", async () => { |
There was a problem hiding this comment.
Renamed in f3e1ff9 to "includes offset in the request-cache key so distinct pages don't collide", matching what the test asserts.
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
| * integer, and ignored entirely when `cursor` is set (cursor wins). | ||
| */ | ||
| offset?: number; | ||
| /** |
There was a problem hiding this comment.
Instead of hving two mutually exclusive, optional properties on this, could you change it to a union type. Use a base type, with two different subtypes for the offset and cursor variants and export CollectionFilter as the union of these. This will mean it will show up as a type error if a user passes both.
There was a problem hiding this comment.
Done in b57ac46. Split into a shared CollectionFilterBase plus CursorCollectionFilter (offset?: never) and OffsetCollectionFilter (cursor?: never) variants, with CollectionFilter exported as their union — applied to both the public query filter and the loader filter, so supplying both is now a compile-time error. The internal query→loader mapping spreads only the active key (cursor wins) so it still satisfies the union, and there's a @ts-expect-error test locking in the both-at-once rejection.
…archives (emdash-cms#1364) getEmDashCollection only supported cursor/keyset pagination, so numbered archive routes (/page/2, /tag/security/page/3) had to either walk cursors page by page or over-fetch from the start and slice — fetching up to 1,000 rows to render 20 on /page/50. Add an `offset` option to the public CollectionFilter and the loader filter, applied as a SQL OFFSET (cross-dialect: SQLite needs a LIMIT, so a bare offset uses LIMIT -1; Postgres takes a standalone OFFSET). Offset and cursor are mutually exclusive — cursor (keyset) wins when both are set so they don't stack into a double skip. Only a positive integer applies. Also surface `hasMore` on CollectionResult whenever a limit is set, computed from the existing over-fetch-by-one, so archive routes can render a next-page link without an extra count query. Offset calls bypass limit bucketing (their limit is part of the pagination contract) and the offset is part of the request-cache key so distinct pages don't collide. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…sh-cms#1364) The test verifies that offset IS part of the request-cache key (so numbered-archive pages don't collide), but its name read as the inverse. Rename to match the behavior, per PR review. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nion (emdash-cms#1364) cursor and offset are mutually exclusive ways to express "the next page", but as two optional properties on one interface, supplying both compiled fine and silently relied on the runtime "cursor wins" fallback. Split the filter into a shared CollectionFilterBase plus CursorCollectionFilter and OffsetCollectionFilter variants, and export CollectionFilter as their union, in both the public query API and the loader filter — so passing both is now a compile-time error. The query->loader mapping spreads only the active pagination key (cursor wins) rather than emitting both, so the literal still satisfies the union. Adds a compile-time test asserting the both-at-once case is rejected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rebasing onto main surfaced a gap: emdash-cms#1619 introduced a cached snapshot layer (loadCollectionCached / CachedCollectionValue) that round-trips collection results but did not carry the `hasMore` flag this PR adds. Offset-paginated callers therefore saw `hasMore: undefined` instead of the over-fetch-derived boolean. Persist and revive `hasMore` in the snapshot so numbered-archive pages get a correct final-page signal. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b57ac46 to
5464f24
Compare
|
@marcusbellamyshaw-cell Can you run |
There was a problem hiding this comment.
This is a sensible, additive fix for numbered archive routes (#1364). Offset pagination fits the existing loader/query split: the loader applies a SQL OFFSET, the query layer reuses its over-fetch logic to compute hasMore, and offset joins the request-cache key so distinct pages don't collide. The approach is the right one for /page/N style archives, and the changeset correctly documents it as a user-facing additive change.
For this re-review I walked through:
- SQL construction in
loader.ts:offsetis validated to a positive integer before use and interpolated through Kysely's tagged-template parameters, so there is no SQL injection risk. SQLite'sLIMIT -1 OFFSET Npath and Postgres's standaloneOFFSET Nare faithful to the dialects. - Locale handling: offset is applied after the existing
localeWHEREclause; no new locale filter bug. hasMoresemantics: the no-bucket path computesentries.length > requestedLimitcorrectly, andsliceCollectionResultonly setshasMore: trueafter the earlycached.entries.length <= limitreturn, so small buckets with fewer total entries than the request still returnhasMore: false.- Bucketing bypass:
bucketFiltertreats offset-bearing filters like cursor-bearing ones, preserving the pagination contract. - Cache key:
filter.offsetis part ofcollectionCacheKey, so pages 0 and 20 don't share snapshots. - Type safety: the
CursorCollectionFilter | OffsetCollectionFilterunion makescursorandoffsetmutually exclusive at compile time while still allowing neither. - Tests: the new unit tests cover skip behavior, disjoint pages, past-the-end pages, cursor-wins, query forwarding,
hasMoretrue/false/undefined, cache-key separation, and bucketing exemption.
The previous review's only note was a misleading test name; on re-reading the current branch, all test names match the assertions, so that appears to have been addressed.
No blocking issues. LGTM.
|
Branch is already formatted — The red check is Auto Format ( |
What does this PR do?
getEmDashCollectiononly supported cursor/keyset pagination, so numbered archive routes (/page/2,/tag/security/page/3) had no efficient option — they had to either walk cursors page-by-page or over-fetch from the start and slice (fetching up to 1,000 rows to render 20 on/page/50).This adds an additive
offsetoption to the publicCollectionFilter(and the underlying loader filter), so numbered archives become a one-liner:Details:
offsetis applied as a SQLOFFSET, cross-dialect: SQLite only acceptsOFFSETwith aLIMIT, so a bare offset usesLIMIT -1(unbounded); Postgres takes a standaloneOFFSET.offsetandcursorare mutually exclusive — cursor (keyset) wins when both are set, so the two don't stack into a double skip. Only a positive integer applies (0 / negative / fractional are no-ops).hasMoreonCollectionResult, set wheneverlimitis provided, computed from the existing over-fetch-by-one — so archive routes can render a "next page" link without an extra count query.offsetis part of the request-cache key so distinct pages don't collide.Closes #1364
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runAI-generated code disclosure
Screenshots / test output
New tests:
loader-offset-pagination.test.ts(skip/disjoint pages/past-end/cursor-wins),query-collection-offset.test.ts(offset forwarding,hasMoresemantics, cache-key separation), plus a bucketing-exemption case inquery-collection-bucketing.test.ts.