Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/db/drizzle/0041_add_holds_hash.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Add holds_hash column to board_climbs table for duplicate detection
ALTER TABLE "board_climbs" ADD COLUMN "holds_hash" text;--> statement-breakpoint
-- Create index for efficient duplicate lookups by board type, layout, and holds hash
CREATE INDEX IF NOT EXISTS "board_climbs_holds_hash_idx" ON "board_climbs" ("board_type","layout_id","holds_hash");--> statement-breakpoint
-- Create unique partial index to prevent duplicate climbs at the database level
-- Only enforced for non-empty holds_hash values (climbs without valid frames are excluded)
CREATE UNIQUE INDEX IF NOT EXISTS "board_climbs_holds_hash_unique_idx" ON "board_climbs" ("board_type","layout_id","holds_hash") WHERE "holds_hash" IS NOT NULL AND "holds_hash" != '';
7 changes: 7 additions & 0 deletions packages/db/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@
"when": 1768444800000,
"tag": "0040_add_climb_user_id",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1768531200000,
"tag": "0041_add_holds_hash",
"breakpoints": true
}
]
}
78 changes: 78 additions & 0 deletions packages/db/scripts/backfill-holds-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Backfill script to compute and populate holds_hash for existing climbs.
*
* Run this script after applying the migration that adds the holds_hash column.
* It will process climbs in batches to avoid memory issues with large datasets.
*
* Usage:
* cd packages/db
* npx tsx scripts/backfill-holds-hash.ts
*/

import { drizzle } from 'drizzle-orm/neon-serverless';
import { neon } from '@neondatabase/serverless';
import { eq, isNull } from 'drizzle-orm';
import { boardClimbs } from '../src/schema/boards/unified';
import { generateHoldsHash } from '../src/utils/holds-hash';

async function backfillHoldsHash() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.error('DATABASE_URL environment variable is required');
process.exit(1);
}

const sql = neon(databaseUrl);
const db = drizzle(sql);

const BATCH_SIZE = 1000;
let totalProcessed = 0;
let totalUpdated = 0;

console.log('Starting holds_hash backfill...');

// Process climbs without a holds_hash
while (true) {
// Fetch a batch of climbs that need processing
const climbs = await db
.select({
uuid: boardClimbs.uuid,
frames: boardClimbs.frames,
boardType: boardClimbs.boardType,
layoutId: boardClimbs.layoutId,
})
.from(boardClimbs)
.where(isNull(boardClimbs.holdsHash))
.limit(BATCH_SIZE);

if (climbs.length === 0) {
console.log('No more climbs to process.');
break;
}

console.log(`Processing batch of ${climbs.length} climbs...`);

// Update each climb with its computed hash
for (const climb of climbs) {
if (climb.frames) {
const hash = generateHoldsHash(climb.frames);
if (hash) {
await db
.update(boardClimbs)
.set({ holdsHash: hash })
.where(eq(boardClimbs.uuid, climb.uuid));
totalUpdated++;
}
}
totalProcessed++;
}

console.log(`Processed ${totalProcessed} climbs, updated ${totalUpdated} with hash...`);
}

console.log(`\nBackfill complete!`);
console.log(`Total climbs processed: ${totalProcessed}`);
console.log(`Total climbs updated with hash: ${totalUpdated}`);
}

backfillHoldsHash().catch(console.error);
3 changes: 3 additions & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ export * from './relations/index';

// Re-export client
export * from './client/index';

// Re-export utilities
export * from './utils/holds-hash';
8 changes: 8 additions & 0 deletions packages/db/src/schema/boards/unified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,8 @@ export const boardClimbs = pgTable('board_climbs', {
syncError: text('sync_error'),
// Boardsesh user who created this climb locally (null for Aurora-synced climbs)
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
// Hash of holds for duplicate detection (sorted holdId:state pairs)
holdsHash: text('holds_hash'),
}, (table) => ({
boardTypeIdx: index('board_climbs_board_type_idx').on(table.boardType),
layoutFilterIdx: index('board_climbs_layout_filter_idx').on(
Expand All @@ -260,6 +262,12 @@ export const boardClimbs = pgTable('board_climbs', {
table.edgeBottom,
table.edgeTop,
),
// Index for efficient duplicate detection
holdsHashIdx: index('board_climbs_holds_hash_idx').on(
table.boardType,
table.layoutId,
table.holdsHash,
),
// Note: No FK to board_layouts - climbs may reference layouts that don't exist during sync
}));

Expand Down
81 changes: 81 additions & 0 deletions packages/db/src/utils/holds-hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Utility for generating a deterministic hash from climb frames.
* Used for duplicate detection - same holds with same states produce the same hash.
*
* This is in the db package so it can be shared between:
* - packages/web (saveClimb, shared-sync)
* - packages/db/scripts (backfill script)
*/

interface HoldStatePair {
holdId: number;
roleCode: number;
}

/**
* Parse a frames string to extract hold-state pairs.
* Frames format: "p{holdId}r{roleCode}p{holdId}r{roleCode}..."
* Multiple frames are separated by commas.
*
* For duplicate detection, we flatten all frames since we care about
* the complete set of holds, not their frame organization.
*/
function parseFramesToHoldStatePairs(frames: string): HoldStatePair[] {
const pairs: HoldStatePair[] = [];

// Split by frames (comma-separated), then process each frame
const frameStrings = frames.split(',').filter(Boolean);

for (const frameString of frameStrings) {
// Parse format: p{holdId}r{roleCode}p{holdId}r{roleCode}...
const holdMatches = frameString.matchAll(/p(\d+)r(\d+)/g);

for (const match of holdMatches) {
pairs.push({
holdId: parseInt(match[1], 10),
roleCode: parseInt(match[2], 10),
});
}
}

return pairs;
}

/**
* Generate a deterministic hash string from a frames string.
*
* The hash is a canonical string representation of sorted hold-state pairs:
* "holdId:roleCode|holdId:roleCode|..."
*
* This ensures the same holds with the same states always produce
* the same hash, regardless of the order they appear in the frames.
*/
export function generateHoldsHash(frames: string): string {
if (!frames || frames.trim() === '') {
return '';
}

const pairs = parseFramesToHoldStatePairs(frames);

if (pairs.length === 0) {
return '';
}

// Sort pairs by holdId first, then by roleCode for determinism
pairs.sort((a, b) => {
if (a.holdId !== b.holdId) {
return a.holdId - b.holdId;
}
return a.roleCode - b.roleCode;
});

// Create canonical string: "holdId:roleCode|holdId:roleCode|..."
return pairs.map(p => `${p.holdId}:${p.roleCode}`).join('|');
}

/**
* Check if two frames strings represent the same set of holds.
*/
export function framesAreEquivalent(frames1: string, frames2: string): boolean {
return generateHoldsHash(frames1) === generateHoldsHash(frames2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default async function CreateClimbPage(props: CreateClimbPageProps) {
return (
<MoonBoardCreateClimbForm
layoutFolder={layoutInfo.folder}
layoutName={layoutInfo.name}
layoutId={parsedParams.layout_id}
holdSetImages={holdSetImages}
angle={parsedParams.angle}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default async function ImportPage(props: ImportPageProps) {
<MoonBoardBulkImport
layoutFolder={layoutInfo.folder}
layoutName={layoutInfo.name}
layoutId={parsedParams.layout_id}
holdSetImages={holdSetImages}
angle={parsedParams.angle}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ import React from 'react';

import { PropsWithChildren } from 'react';

import { BoardRouteParameters, ParsedBoardRouteParameters } from '@/app/lib/types';
import { BoardRouteParameters, ParsedBoardRouteParameters, BoardDetails } from '@/app/lib/types';
import { parseBoardRouteParams, constructClimbListWithSlugs } from '@/app/lib/url-utils';
import { parseBoardRouteParamsWithSlugs } from '@/app/lib/url-utils.server';
import { getBoardDetails } from '@/app/lib/__generated__/product-sizes-data';
import { getMoonBoardDetails } from '@/app/lib/moonboard-config';
import { permanentRedirect } from 'next/navigation';
import ListLayoutClient from './layout-client';

// Helper to get board details for any board type
function getBoardDetailsForBoard(params: ParsedBoardRouteParameters): BoardDetails {
if (params.board_name === 'moonboard') {
return getMoonBoardDetails({
layout_id: params.layout_id,
set_ids: params.set_ids,
});
}
return getBoardDetails(params);
}

interface LayoutProps {
params: Promise<BoardRouteParameters>;
}
Expand All @@ -30,7 +42,7 @@ export default async function ListLayout(props: PropsWithChildren<LayoutProps>)
parsedParams = parseBoardRouteParams(params);

// Redirect old URLs to new slug format
const boardDetails = await getBoardDetails(parsedParams);
const boardDetails = getBoardDetailsForBoard(parsedParams);

if (boardDetails.layout_name && boardDetails.size_name && boardDetails.set_names) {
const newUrl = constructClimbListWithSlugs(
Expand All @@ -50,7 +62,7 @@ export default async function ListLayout(props: PropsWithChildren<LayoutProps>)
}

// Fetch the climbs and board details server-side
const boardDetails = await getBoardDetails(parsedParams);
const boardDetails = getBoardDetailsForBoard(parsedParams);

return <ListLayoutClient boardDetails={boardDetails}>{children}</ListLayoutClient>;
}
Loading
Loading