Skip to content

moooon

Motir

Vibe your whole project. Bring an idea — Motir's three AI layers plan it, track it, and ship it, end to end. You're looking at Motir, built in Motir.

  • Vibe Project
  • Open Source
  • AI Agent
  • AI Loop
1
requests
0
upvotes
145
planned
1,361
shipped

Motir · Work items

MOTIR-2059Done

11.3.2 The ORDER-AGNOSTIC v1 page cursor — a signed, collection-scoped envelope around a service-owned position

The shared pagination primitive every other read in 11.3 composes. Extends lib/api/v1/pagination.ts so a v1 cursor can name a position in a sort order the service owns, instead of only the (createdAt, id) order 11.1 hardwired.

Implements what 11.3.1 decides in Q1; do not re-open the decision here, and do not proceed if the amendment's Q1 differs from the sketch below — the amendment is the contract, this card is its implementation.

Why the shipped codec cannot serve this story

PageCursor is { createdAt: string; id: string } and paginateKeyset constrains its rows to Keyed { id: string; createdAt: Date }. Four of this story's collections sort on something else — backlogRank (backlog, sprint members), the (type asc, priority desc, key asc) dispatch rank (the ready set), sequence (sprints) — and two of the DTOs involved cannot satisfy Keyed at all (SprintDto has no createdAt; ProjectDTO.createdAt is optional and unloaded on the list path). Paging any of them through today's codec would silently return the collection in a different order than the product's own surfaces show.

The change

Add, do not replace. encodePageCursor / decodePageCursor / parsePageRequest / paginateKeyset stay exactly as they are — 11.2's six shipped endpoints and GET /api/v1/workspaces depend on them and must not change behaviour. The new surface sits beside them:

  • A signed, opaque envelope over an arbitrary position payload, reusing the SAME HMAC construction and the same BETTER_AUTH_SECRET-derived signing key — so a cursor issued by one deployment stays invalid against another, and the existing timingSafeEqual comparison is not re-implemented.
  • Collection-scoped. The signed payload names the collection that issued it, and decoding under a different collection is the same 422 as a tampered token — not a silently decoded position in an order where it means nothing. This is the property that keeps a backlog cursor from paging the ready set.
  • A parse helper that reads ?cursor=&limit= for a service-positioned collection, applying v1's OWN ceiling: limit defaults to 50 and clamps to 100, even where the underlying read permits more (READY_MAX_LIMIT is 200 — v1 clamps down, it does not raise its documented cap). The limit validation rules are unchanged: 0, negative, fractional and non-numeric are 422, over-cap is clamped.
  • The existing { items, nextCursor } envelope type is reused; if 11.3.1 Q2 admits a ranked variant carrying totalCount, this module is where its type lives, so 11.4 has one place to emit from.

No route, no service, no schema module in this card — those are the endpoint cards. This ships the primitive plus its unit tests.

Acceptance criteria

  • A v1 cursor can be issued and re-read for a position that is NOT (createdAt, id) — covered by a test for each of the three position shapes this story actually needs: an opaque row id (backlogRank order), the ready set's (kind, priority, key) tuple, and a sprint sequence.
  • A cursor issued for collection A is refused with 422 when presented to collection B, with the same INVALID_CURSOR code as a tampered token — asserted directly, since this is the property that makes the generalization safe rather than merely convenient.
  • A tampered, truncated, foreign-deployment or non-JSON cursor is a 422 and NEVER a silent reset to page one — the existing property, re-asserted on the new surface (a cursor whose signature verifies but whose payload is not a valid position is included, the case 11.1's own gate found).
  • limit clamps to 100 on a collection whose service allows more, asserted against the ready set's 200 specifically; 0 / -1 / 1.5 / abc are each 422.
  • The shipped encodePageCursor / decodePageCursor / paginateKeyset behaviour is UNCHANGED — the existing tests/api/v1/pagination.test.ts passes untouched, and a cursor encoded before this change still decodes after it.
  • Unit tests ship with the module (the per-subtask floor) and the new file holds the ≥90% per-file coverage gate.
  • ONE PR against motir-core.

Context refs

  • lib/api/v1/pagination.ts — the module this extends: PageCursor, Keyed, signingKey, sign, encodePageCursor, decodePageCursor, parsePageRequest, paginateKeyset, DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT.
  • lib/api/v1/errors.tsInvalidRequestError and the INVALID_CURSOR code the 422 carries.
  • lib/workItems/readyFilter.ts — the ready position (ReadyCursor, encodeReadyCursor, decodeReadyCursor) this must be able to carry, and READY_MAX_LIMIT = 200.
  • lib/services/backlogService.tsgetBacklog / getSprintIssues, whose cursor is the last row's id.
  • tests/api/v1/pagination.test.ts — the shipped suite that must keep passing unchanged.
  • docs/decisions/public-api-conventions.md §5 + Amendment 3 (written by 11.3.1) — the contract this implements.
  • Parent story: 11.3.