feat(04-04): implement item CRUD endpoints + fractional rank (LIST-02)

- Add rank.ts: rankForAppend/rankBetween wrapping fractional-indexing (D-13)
- Extend listsRouter: POST /:id/items (fractional rank at active-bottom),
  GET /:id/items (rank ASC, access-gated)
- Add listItemsRouter (mounted /api/list-items): PATCH /:itemId per-field LWW
  (exactly-one-field zod refine D-08/T-04-07), DELETE /:itemId delete-wins (D-09)
- Uncheck recomputes rank to active-bottom in same write (Open Question 2)
- All item handlers: access-gate via checkListAccess (T-04-05)
- Plan 06 SSE seam comments at each mutation handler
- All 48 tests green; typecheck passes
This commit is contained in:
Lucas Berger
2026-06-09 12:55:32 -04:00
parent b1dc9b8048
commit 5e3151416c
3 changed files with 341 additions and 2 deletions
+42
View File
@@ -0,0 +1,42 @@
/**
* Fractional-indexing rank helpers (D-13).
*
* Wraps the `fractional-indexing` library for use in list item ordering.
* String-based ranks (e.g. "a0", "a1", "a0V") are stable — a single reorder
* writes only the moved item's rank, playing well with SSE live sync (D-13, D-15).
*
* API:
* rankForAppend(lastRank) → rank that sorts after `lastRank` (or "a0" when null)
* rankBetween(prev, next) → rank that sorts between `prev` and `next`
*
* Both are pure functions — no DB access, no side effects.
*/
import { generateKeyBetween } from 'fractional-indexing'
/**
* Generate a rank suitable for appending an item AFTER the last active item.
*
* @param lastRank - the rank of the current last active item, or null if the
* list is empty (first item).
* @returns a rank string that sorts after `lastRank` when ordered ASC.
* An empty list gets "a0" (generateKeyBetween(null, null)).
*/
export function rankForAppend(lastRank: string | null): string {
return generateKeyBetween(lastRank, null)
}
/**
* Generate a rank suitable for inserting between two existing ranks.
* Also serves as the general case: pass (null, null) for empty list,
* (null, next) for prepend, (prev, null) for append.
*
* @param prev - rank of the item immediately before the insertion point,
* or null if inserting at the beginning.
* @param next - rank of the item immediately after the insertion point,
* or null if inserting at the end.
* @returns a rank string that sorts between `prev` and `next` when ordered ASC.
*/
export function rankBetween(prev: string | null, next: string | null): string {
return generateKeyBetween(prev, next)
}