diff --git a/apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql b/apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql new file mode 100644 index 0000000..2c1aa71 --- /dev/null +++ b/apps/api/src/db/migrations/0002_yielding_mattie_franklin.sql @@ -0,0 +1 @@ +ALTER TABLE `list_items` MODIFY COLUMN `rank` varchar(255) COLLATE utf8mb4_bin NOT NULL; \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index ea7d8d9..6ebd148 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -10,8 +10,20 @@ import { boolean, index, unique, + customType, } from 'drizzle-orm/mysql-core' +// Custom varchar type with explicit binary collation. +// Drizzle 0.45.x does not expose a first-class collation option on varchar, +// so we use customType to emit `varchar(255) COLLATE utf8mb4_bin`. +// utf8mb4_bin is required for fractional-indexing rank keys: uppercase-prefixed +// ranks (e.g. 'Zz') must sort BEFORE lowercase ranks (e.g. 'a0') — matching JS +// string order — so that drag-to-top persists across a DB ORDER BY rank. +const varcharBin = (name: string) => + customType<{ data: string; driverData: string }>({ + dataType: () => 'varchar(255) COLLATE utf8mb4_bin', + })(name) + // ── Phase 4: List tables ─────────────────────────────────────────────────── // Imported by test/setup.ts for afterEach cleanup — keep exports consistent. @@ -228,7 +240,7 @@ export const listItems = mysqlTable( .references(() => lists.id, { onDelete: 'cascade' }), text: varchar('text', { length: 500 }).notNull(), checked: boolean('checked').default(false).notNull(), - rank: varchar('rank', { length: 255 }).notNull(), // fractional-indexing string (D-13) + rank: varcharBin('rank').notNull(), // fractional-indexing string (D-13) — COLLATE utf8mb4_bin createdAt: timestamp('created_at').defaultNow().notNull(), updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(), },