dorfteich/apps/api/src/pages/sort-key.ts
Claude Opus 4.8 69b00fcf2f
All checks were successful
CD / Build and push images (push) Successful in 3m1s
CI / Lint, typecheck, test (push) Successful in 2m13s
CI / Auth e2e pack (push) Successful in 2m36s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m13s
CD / Promote to Int (push) Successful in 11s
Add manual page ordering with drag-and-drop (#45)
Enable the third sidebar sort mode — a freely defined order.

- api: `PATCH /pages/:id/position` (before/after neighbour) recomputes only
  the moved page's fractional `sort_key`. Pure `sort-key.ts` helpers
  (`nextKeyOrRebalance`, `evenlySpacedKeys`) decide between the cheap
  single-key path and a full pond rebalance to evenly-spaced keys when a key
  would exceed MAX_SORT_KEY_LENGTH or the client's neighbours are stale;
  rebalance runs in one transaction. Order is server-authoritative.
- web: enable 'manual' in the sort-mode switch; in manual mode the owner can
  reorder via native drag-and-drop (drop above/below by pointer half) or the
  keyboard (per-row up/down buttons), each announced through an aria-live
  region. Reordering is hidden while a label filter narrows the list. New
  pages already append at the end (create uses generateKeyBetween(last, null)).
  Pure `reorder.ts` neighbour helpers, unit-tested.
- i18n: manual sort mode + reorder strings (de + en).
- tests: sort-key property test (10.000 adversarial reorders never collide or
  overflow — rebalance verified); reposition db test (persist, server-order,
  sort-mode switch keeps manual order); reorder e2e pack (keyboard reorder
  persists across reload + identical on a fresh read; aria-live announced).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PGdhRiwU1WRL4XxJfZYipY
2026-07-09 12:18:44 +02:00

53 lines
2.2 KiB
TypeScript

import { generateKeyBetween, generateNKeysBetween } from 'fractional-indexing';
/**
* Fractional-index keys for manual page ordering (issue #45, data-model.md
* `sort_key`). A move recomputes only the moved page's key as a value strictly
* between its two new neighbours, so no sibling is rewritten. Repeatedly
* inserting between two very close keys grows the key length; once a fresh key
* would exceed {@link MAX_SORT_KEY_LENGTH} the caller rebalances the whole pond
* to evenly-spaced keys instead. These helpers are pure so the rebalance
* behaviour can be property-tested without a database.
*/
/**
* Length at which a newly generated key triggers a rebalance. Fractional keys
* only grow under adversarial "always insert between the same tight pair"
* sequences; a healthy tree stays far below this. 40 leaves generous headroom
* over normal use while capping unbounded growth.
*/
export const MAX_SORT_KEY_LENGTH = 40;
/** A key strictly between `after` and `before` (either `null` for an open end). */
export function keyBetween(after: string | null, before: string | null): string {
return generateKeyBetween(after, before);
}
/** `n` evenly-spaced keys spanning the whole range — used to rebalance a pond. */
export function evenlySpacedKeys(n: number): string[] {
if (n <= 0) return [];
return generateNKeysBetween(null, null, n);
}
/**
* The key for a page moved between `afterKey` and `beforeKey`, or `null` when
* the result would be too long (or the neighbours are out of order, e.g. from a
* stale client) and the caller must rebalance instead. Never throws.
*/
export function nextKeyOrRebalance(
afterKey: string | null,
beforeKey: string | null,
): string | null {
// Guard reversed/equal neighbours ourselves: generateKeyBetween throws only
// on equal keys and silently returns a wrong key when after > before, so a
// stale client could otherwise corrupt the order — force a rebalance instead.
if (afterKey !== null && beforeKey !== null && afterKey >= beforeKey) return null;
let key: string;
try {
key = keyBetween(afterKey, beforeKey);
} catch {
return null;
}
return key.length > MAX_SORT_KEY_LENGTH ? null : key;
}