Skip to content

Fractional indexing for kanban ordering#41

Open
hobbescodes wants to merge 7 commits intomasterfrom
refactor/fractional-indexing
Open

Fractional indexing for kanban ordering#41
hobbescodes wants to merge 7 commits intomasterfrom
refactor/fractional-indexing

Conversation

@hobbescodes
Copy link
Copy Markdown
Contributor

Description

Replaces the integer column_index / index columns on task, column, project, and project_column with text-based fractional-indexing keys, so reordering an item is a single-row update from its destination's two neighbors instead of N sibling rewrites.

PostGraphile auto-derives the GraphQL types from the column types, so columnIndex / index fields flip from Int to String. The COLUMN_INDEX_ASC / INDEX_ASC orderBy enums keep working because lex sort on text matches base-62 ascending order.

Migration sequence (single PR, all three apply via bun db:migrate):

  1. 0051_cooing_vulture (auto): add nullable text companion columns alongside the existing int cols.
  2. 0052_backfill_fractional_indices (custom SQL): PL/pgSQL helper produces length-3 base-62 keys (header c), partitioned per parent (column_id, project_id, project_column_id, organization_id), ordered by existing int + created_at + id.
  3. 0053_graceful_captain_midlands (custom SQL): cutover via ALTER COLUMN ... SET DATA TYPE text USING <companion>, drops the companions, adds the new (project_column_id, column_index) composite index.
  4. 0054_collate_c_for_fractional_indices (custom SQL): forces COLLATE \"C\" on the four columns so Postgres ORDER BY uses byte order, matching the library's assumption ('Zz' < 'a0'). Without this, en_US.UTF-8 collation puts Zz after a0 and dragging an item to the top of a column persists a correct key but the server returns rows in the wrong order on refetch.

Insert-time key generation:

  • DefaultColumnsPlugin: replaces hardcoded index: 0..4 with generateNKeysBetween(null, null, 5) so seeded columns get valid fractional keys.
  • IDP webhook (handleOrganizationCreated): same treatment for default project columns.
  • generateGraphqlSchema.ts: registers fractional-indexing in the EXPORTABLE module map so graphile-export can serialize generateNKeysBetween into the executable schema.

Coordinated deploy required: the GraphQL schema flips IntString after this PR lands. The runa-app PR (omnidotdev/runa-app#TBD) consumes the new types. Deploy both services together; in-flight reorder mutations from older app instances will fail with type errors during the window between API restart and App restart.

Test Steps

  • bun db:check clean before and after each migration
  • bun db:migrate applies 0051 → 0052 → 0053 → 0054 in order, regenerates GraphQL schema, all four columns are text NOT NULL with COLLATE \"C\" (\\d task, \\d \"column\", \\d project, \\d project_column)
  • SELECT proname FROM pg_proc WHERE proname = 'fi_seed_key' returns 0 rows after migration
  • 'Zz'::text COLLATE \"C\" < 'a0'::text COLLATE \"C\" returns t
  • After migrate, bun db:generate reports "No schema changes" (snapshot matches DB)
  • bun run check && bun run build && bun test all pass
  • src/generated/graphql/schema.graphql shows columnIndex: String! / index: String!, IntFilter becomes StringFilter, numeric aggregates removed for the four columns
  • Creating a new project via the API seeds 5 default columns with valid fractional keys (a0, a1, …)

Replaces integer column_index / index columns on task, column, project,
and project_column with text-based fractional-indexing keys so reordering
becomes a single-row update instead of N sibling rewrites.

Three-migration sequence:
- 0051: add nullable text companion columns alongside existing int cols
- 0052: backfill via PL/pgSQL helper producing length-3 base-62 keys,
  partitioned per parent (column_id, project_id, project_column_id,
  organization_id), ordered by existing int + created_at + id
- 0053: cutover with ALTER COLUMN SET DATA TYPE text USING <companion>,
  drops the companions, adds the new (project_column_id, column_index)
  composite index for project board ordering

PostGraphile auto-derives GraphQL types from column types; columnIndex
and index fields flip from Int to String on the next graphql:generate.
The COLUMN_INDEX_ASC / INDEX_ASC orderBy enums keep working because lex
sort on text matches base-62 ascending order.
Replaces hardcoded numeric indices in DefaultColumnsPlugin and the IDP
webhook with generateNKeysBetween calls so newly seeded columns and
project columns get valid fractional keys at insert time. Registers
fractional-indexing in the EXPORTABLE module map so graphile-export
can serialize generateNKeysBetween into the generated executable schema.

Regenerates schema.graphql and schema.executable.ts: ordering fields
flip from Int to String, IntFilter inputs become StringFilter, numeric
aggregates (sum, avg, stddev, variance, having-int) drop off the text
columns, and COLUMN_INDEX_ASC / INDEX_ASC orderBy enums survive (lex
sort on text matches base-62 ascending).
Postgres default DB collation is en_US.UTF-8, which sorts uppercase Z
after lowercase a (e.g. 'a0' < 'Zz'). The fractional-indexing library
produces keys assuming byte-order lex comparison, where 'Zz' < 'a0'.
Without this fix, dragging an item to the top of a column persists a
correct key (Zz) but the server returns rows in the wrong order on
refetch, so the moved item snaps back to the bottom.

Switching the four ordering columns to COLLATE "C" makes ORDER BY use
byte order. Postgres auto-rebuilds dependent composite indexes with
the new collation. Drizzle does not track column collation in
snapshots, so no schema-file change is required.
@hobbescodes hobbescodes changed the title refactor: fractional indexing for kanban ordering Fractional indexing for kanban ordering May 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant