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-2041Done

11.2.3 The keyset-paged project work-item read — repository + service, the SAME filter predicate as `/items`, ordered `(createdAt, id)`

The one card in this story that touches the service layer, and it does so under the bounded carve-out 11.2.1 records: a new page ADDRESSING over an existing predicate, not new behaviour. The result SET is identical to what getProjectIssuesList returns for the same filter; only how a page inside it is named changes.

Why it cannot be avoided, verified on origin/main @ c4ec51b1: getProjectIssuesList pages by { limit, offset } and clamps to ISSUE_LIST_PAGE_SIZE = 50, while ADR §5 forbids offset and pins a 100 ceiling; paginateKeyset slices a fully-read collection, which over a project holding 1800+ items is the unbounded read this story forbids. No keyset read of work items exists in lib/repositories/.

What to build

1. workItemRepository.findProjectIssuesKeyset(projectId, workspaceId, filter, { after, limit }) — the same shape as findProjectIssuesFlat, with the offset window replaced by a keyset one:

  • The predicate is the SAME compiled predicate, not a second query path. Reuse whatever findProjectIssuesFlat already composes for RepoIssueFilter (including the registry's parameterized AST compiler) by extracting the shared WHERE construction — never re-express a filter axis. A second predicate is how the API and the web app start disagreeing about what a filter means, which the parity criterion below exists to catch.
  • Order is (createdAt ASC, id ASC) — a TOTAL order, so a position is unambiguous when timestamps collide. This deliberately is NOT the List view's IssueSort: the v1 cursor encodes { createdAt, id } (lib/api/v1/pagination.ts), and it is the RESULT SET that must match the web app, not the row order. Say so in the method's comment so a later reader does not "fix" it toward DEFAULT_SORT.
  • after is { createdAt, id } and compiles to the standard tuple comparison (createdAt > $1 OR (createdAt = $1 AND id > $2)), bound as parameters.
  • Fetch limit + 1 rows to learn whether a next page exists without a second COUNT — the count is the offset pager's denominator and has no purpose here.

2. workItemsService.listProjectWorkItemsPage(projectId, { filter, after, limit }, ctx) — the gates and filter resolution of getProjectIssuesList, unchanged:

  • cross-tenant projectIdProjectNotFoundError (no existence leak), then projectAccessService.assertCanBrowse;
  • loadFilterReferents + buildRepoFilter for the AST axes, so label / component / custom-field conditions resolve exactly as the List resolves them, and a stale referent still compiles to match-nothing rather than erroring;
  • limit clamped to the v1 ceiling (MAX_PAGE_LIMIT = 100), NOT to ISSUE_LIST_PAGE_SIZE. The List's 50-row cap is a Cloud performance bound on the offset pager and is left exactly as it is — this card raises no existing cap and changes no existing method.
  • Returns the rows plus whether more remain, leaving cursor ENCODING to the route (the cursor is a v1-layer concept; a service must not learn one).

3. Its tests — real Postgres, not mocks.

Scope BOUNDARY

Ends at the read. It ships no route (11.2.4 is the endpoint), no cursor encoding, no response schema, and touches no write path. It adds no filter axis, no access gate and no field — the carve-out's bounds are exactly this, and a deliverable outside them belongs in the owning feature's epic, not here. getProjectIssuesList and every existing caller are left untouched, so the /items view and search_work_items are unaffected.

Acceptance criteria

  • The keyset read returns, for any filter, the SAME SET as getProjectIssuesList walked to exhaustion for that filter — asserted by walking both over a seeded project with an AST filter spanning a built-in axis and an Epic-5 axis (a label or custom field), and comparing the key sets. This is the one-grammar contract at the data layer.
  • A concurrent INSERT during a paged scan never causes a skip or a duplicate: page 1, insert a row whose createdAt sorts before the cursor, then page 2 — every original row is seen exactly once. A pagination test that only walks a static fixture has not tested pagination (ADR §5).
  • Deleting a row mid-scan does not shift a later page's contents onto rows already seen.
  • The last page reports "no more" rather than requiring an extra empty round trip; a cursor past the tail yields an empty page, not an error.
  • limit is honoured up to 100 and a request above it is clamped, with a test proving the read is NOT capped at 50.
  • A cross-workspace projectId raises ProjectNotFoundError; a project the caller cannot browse raises the access error — asserted, since this method carries its own gates rather than inheriting a route's.
  • The filter predicate is shared with the existing flat read at the source level: a test that adds a condition to the shared builder proves both reads change together (or the shared function is exercised by both suites), so the two cannot drift.
  • An injection probe in a filter VALUE binds as a parameter and matches nothing.
  • The per-file coverage floor (≥90%) holds on every new and changed file.

Context refs

  • lib/services/workItemsService.tsgetProjectIssuesList (the offset read this parallels), buildRepoFilter, loadFilterReferents, clampIssuePageSize.
  • lib/repositories/workItemRepository.tsfindProjectIssuesFlat and its filter compilation.
  • lib/filters/registry.ts + lib/filters/ast.ts — the parameterized AST compiler and the field set the predicate must keep honouring.
  • lib/api/v1/pagination.tsPageCursor { createdAt, id } and MAX_PAGE_LIMIT, the order and ceiling this read serves.
  • lib/issues/issueListView.tsISSUE_LIST_PAGE_SIZE = 50, the cap that stays where it is.
  • motir-core/CLAUDE.md — the 4-layer contract this card slices along (repository leaf, then service).
  • Decision that authorises it: 11.2.1. Consumer: 11.2.4. Parent story: 11.2.